TL;DR : la télécommande de mon ventilateur de plafond est un émetteur 433 MHz ASK/OOK des plus basiques, alors j’ai donné un cerveau à son signal. Avec un ESP32-S3, un transceiver sub-GHz CC1101 et ESPHome, j’ai construit une petite passerelle qui capture les trames de la télécommande et les rejoue — le ventilateur et sa lumière sont maintenant de vraies entités Home Assistant, pilotables depuis des boutons, des automatisations et des assistants vocaux, sans le moindre cloud tiers. Cet article passe en revue le matériel, la configuration YAML et les pièges (radio half-duplex, choix des broches SPI), avec Hermes en tant que guide IA tout au long du projet.

Mon ESP32 et le CC1101 connectés à une batterie de smartphone

Contexte

Récemment, j’ai découvert Home Assistant et j’ai commencé à plonger dans le terrier du lapin de la domotique. Quelques ampoules connectées plus tard, je voulais aller plus loin. Ma principale galère, c’était de trouver la télécommande de mon ventilateur de plafond : la plupart du temps, je devais la chercher partout juste pour allumer la lumière de la pièce. Surement que Home Assistant pouvait régler ça !

La plupart des ventilateurs de plafond sont pilotés par une télécommande RF (Radio-Fréquence) en 433.92 MHz, modulation ASK/OOK. Cela signifie que la télécommande envoie un signal, et que le récepteur capte la séquence et exécute l’action correspondante.

Exemple de signal RF, tiré de https://quartzcomponents.com/blogs/electronics-projects/wireless-communication-interfacing-433mhz-transmitter-and-receiver-modules-with-arduino-uno

Il suffit donc de capturer le signal, puis de le rejouer. Ce qui implique d’avoir un récepteur et un émetteur.

Il existe des produits qui font exactement cela :

  1. Les ponts RF BroadLink RM4 / Tuya — ça fonctionne, mais ce sont des boîtes noires fermées qui dépendent d’un cloud tiers.
  2. Sonoff RF Bridge — flashable avec ESPHome, mais limité à un seul protocole et à l’ASK/OOK en 433 MHz uniquement.
  3. Un transceiver 433 MHz dédié sur un ESP32 — la voie choisie ici, avec un module radio CC1101 sub-GHz. Il est bon marché, bien documenté, et pleinement supporté par ESPHome via le composant cc1101.

J’ai donc choisi la dernière option. C’était mon point d’entrée dans l’univers ESPHome.

Un point à connaître d’emblée : le matériel RF et le développement embarqué étaient des territoires totalement inconnus pour moi. J’ai mené tout ce projet avec Hermes — un agent IA de Nous Research — en guise de guide : comprendre la modulation ASK/OOK, choisir le module radio, écrire et déboguer la configuration ESPHome. Honnêtement, je n’aurais pas pu mener ce projet sans une IA en soutien.

ESPHome

ESPHome est un framework de firmware qui permet de décrire une carte ESP32 dans un unique fichier YAML : quelles broches sont connectées à quels capteurs, quelles entités exposer, etc. Il compile cette description en une image de firmware, et l’appareil se connecte automatiquement à Home Assistant — pas de cloud, pas d’application propriétaire. Modifier la configuration se résume à un simple edit YAML suivi d’une mise à jour OTA (over-the-air).

Le CC1101 est intéressant car c’est un vrai transceiver (TX et RX) accordable de 300 à 928 MHz, avec une modulation et une puissance d’émission configurables. Contrairement à un émetteur 433 MHz super-régénératif bon marché, il peut à la fois capturer les trames de la télécommande et les rejouer.

Matériel

Le module CC1101

Une carte électronique à 8 broches (lien Amazon) exposant :

PinNomDirectionRôle
1GND-Masse
2VCCinAlimentation, 1,8–3,6 V (3,3 V)
3GDO0outSortie numérique (TX)
4CSNinChip select SPI
5SCKinHorloge SPI
6MOSIinDonnée SPI (entrée)
7MISO/GDO1outDonnée SPI (sortie)
8GDO2outDeuxième sortie numérique (pour RX)

La carte ESP32-S3

Une ESP32-S3-N16R8 (16 Mo de flash, 8 Mo de PSRAM). L’ESP32-S3 dispose d’une matrice GPIO très flexible, mais quelques broches ont des rôles particuliers à connaître :

  • GPIO0 est la broche BOOT/strapping.
  • GPIO19/GPIO20 sont les lignes USB D-/D+.
  • GPIO11/GPIO12/GPIO13/GPIO10 sont les broches FSPI matérielles (FSPID / FSPICLK / FSPIQ / FSPICS0) — idéales pour piloter le CC1101 via IOMUX à pleine vitesse SPI.

Câblage

Pin CC1101GPIO ESP32-S3Fonction
1 (GND)GNDMasse
2 (VCC)3V3Alimentation
3 (GDO0)GPIO7Donnée TX
4 (CSN)GPIO10SPI CS0
5 (SCK)GPIO12Horloge SPI
6 (MOSI)GPIO11SPI MOSI
7 (MISO)GPIO13SPI MISO
8 (GDO2)GPIO21Donnée RX

La configuration ESPHome

ESPHome fournit un composant cc1101 qui encapsule la radio, ainsi que les classiques remote_transmitter / remote_receiver qui implémentent la pile protocolaire 433 MHz par-dessus. Construisons la configuration étape par étape.

Étape 1 : la carte et la radio

D’abord le boilerplate : la carte, le Wi-Fi, et le hub cc1101 lui-même (piloté en SPI). Le trigger on_boot passe la radio en mode RX immédiatement, pour commencer à capturer dès le démarrage :

# Board: Generic ESP32-S3 Board (Generic)
esphome:
  name: esp32-s3-n16r8
  friendly_name: ESP32-S3-N16R8
  on_boot:
    then:
      - cc1101.begin_rx

esp32:
  variant: esp32s3
  framework:
    type: esp-idf

logger:

api:
  encryption:
    key: ...

ota:
  - platform: esphome
    password: !secret esp32_s3_n16r8__ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

captive_portal:

spi:
  interface: hardware # IOMUX, SPI à pleine vitesse
  clk_pin: GPIO12 # SCK  → CC1101 Pin 5  (FSPICLK)
  mosi_pin: GPIO11 # MOSI → CC1101 Pin 6  (FSPID)
  miso_pin: GPIO13 # MISO → CC1101 Pin 7  (FSPIQ)

cc1101:
  cs_pin: GPIO10 # CSN → CC1101 Pin 4 (FSPICS0)
  frequency: 433.92MHz
  output_power: 10
  modulation_type: ASK/OOK
  symbol_rate: 5000
  filter_bandwidth: 200kHz

Étape 2 : le récepteur

Maintenant, le composant remote_receiver. Il écoute sur GDO2 et affiche chaque trame capturée sous forme de liste de durées d’impulsions. Les décodeurs par défaut tentent de « décoder » les impulsions brutes avec tous les protocoles connus — voir le piège ci-dessous — donc on restreint dump: à raw.

Le trigger on_raw a deux jobs : il journalise les impulsions par petits morceaux (afin que chaque ligne tienne dans le buffer de log ESPHome et puisse être copiée-collée depuis les logs), et il stocke la dernière salve dans une variable globale quand le mode apprentissage est activé :

# Global pour stocker le signal appris
globals:
  - id: learned_code
    type: std::vector<int32_t>
    restore_value: no
  - id: learning_mode
    type: bool
    initial_value: "false"
  - id: signal_learned
    type: bool
    initial_value: "false"

# Récepteur RF (via GDO2)
remote_receiver:
  id: rf_receiver
  pin: GPIO21
  tolerance: 50%
  filter: 200us
  dump:
    - raw
  idle: 10ms
  on_raw:
    then:
      - lambda: |-
          // Log in chunks so each line fits the ESPHome log buffer (~256 chars).
          // Reassemble: part0 + ", " + part1 + ", " + ... -> code: [...]
          ESP_LOGI("raw_code", "Pulses: %d", x.size());
          const int chunk = 40;
          for (int i = 0; i < (int)x.size(); i += chunk) {
            std::string s;
            for (int j = i; j < (int)x.size() && j < i + chunk; j++) {
              if (j > i) s += ", ";
              s += std::to_string(x[j]);
            }
            ESP_LOGI("raw_code", "part %d: %s", i / chunk, s.c_str());
          }

          // Learning mode: store this burst and stop listening
          if (id(learning_mode)) {
            id(learned_code).clear();
            for (int32_t val : x) {
              id(learned_code).push_back(val);
            }
            id(signal_learned) = true;
            id(learning_mode) = false;

            std::string preview = "";
            int count = 0;
            for (int32_t val : id(learned_code)) {
              if (count > 0) preview += ", ";
              preview += std::to_string(val);
              count++;
              if (count >= 20) {
                preview += "... (" + std::to_string(id(learned_code).size()) + " total)";
                break;
              }
            }

            id(last_signal).publish_state(preview.c_str());
            id(learn_status).publish_state("Signal learned! " + std::to_string(id(learned_code).size()) + " pulses.");
          }

Piège pour les nouveaux venus : les décodeurs IR (pronto, nec, beo4…) se feront un plaisir de « décoder » les trames RF 433 MHz et produiront des absurdités comme une fréquence Pronto 006D (= IR 38 kHz). Restreindre dump: aux protocoles RF évite des pages de faux positifs dans les logs.

Étape 3 : l’émetteur

L’émetteur est connecté à GDO0. La subtilité, c’est que le CC1101 est une radio half-duplex : elle est en mode RX ou en mode TX, jamais les deux. Les automatisations on_transmit / on_complete de remote_transmitter gèrent la bascule : passer en mode TX juste avant l’envoi, et repasser en mode RX juste après :

# Émetteur RF (via GDO0)
remote_transmitter:
  id: rf_transmitter
  pin: GPIO7
  carrier_duty_percent: 100%
  on_transmit:
    then:
      - cc1101.begin_tx
  on_complete:
    then:
      - cc1101.begin_rx

Étape 4 : les entités exposées à Home Assistant

Enfin, on expose quelques helpers pour que tout le workflow d’apprentissage puisse être piloté depuis Home Assistant (ou l’interface web ESPHome) : un switch pour armer le mode apprentissage, un bouton pour rejouer le signal appris, et des text sensors pour voir ce qui a été capturé :

switch:
  - platform: template
    name: "Learning Mode"
    id: learning_mode_switch
    icon: "mdi:school"
    lambda: "return id(learning_mode);"
    turn_on_action:
      - lambda: |-
          id(learning_mode) = true;
          id(learn_status).publish_state("Waiting for signal... Press remote now!");
      - logger.log: "Learning mode ON - press your remote"
    turn_off_action:
      - lambda: |-
          id(learning_mode) = false;
          id(learn_status).publish_state("Learning cancelled");
      - logger.log: "Learning mode OFF"

button:
  - platform: restart
    name: "Restart"

  - platform: template
    name: "Replay Learned Signal"
    icon: "mdi:replay"
    on_press:
      - lambda: |-
          if (!id(signal_learned) || id(learned_code).empty()) {
            ESP_LOGW("replay", "No signal learned yet!");
            id(learn_status).publish_state("No signal to replay - learn one first!");
            return;
          }
          ESP_LOGI("replay", "Replaying %d pulses...", id(learned_code).size());
          id(learn_status).publish_state("Transmitting...");
      - remote_transmitter.transmit_raw:
          carrier_frequency: 0Hz
          code: !lambda "return id(learned_code);"
      - lambda: |-
          id(learn_status).publish_state("Signal transmitted!");
          ESP_LOGI("replay", "Replay complete");

  - platform: template
    name: "Clear Learned Signal"
    icon: "mdi:delete"
    on_press:
      - lambda: |-
          id(learned_code).clear();
          id(signal_learned) = false;
          id(last_signal).publish_state("(empty)");
          id(learn_status).publish_state("Signal cleared");
          ESP_LOGI("learn", "Learned signal cleared");

text_sensor:
  - platform: template
    name: "Last Received Signal"
    id: last_signal
    icon: "mdi:signal"

  - platform: template
    name: "Learn Status"
    id: learn_status
    icon: "mdi:information"

binary_sensor:
  - platform: template
    name: "Signal Learned"
    icon: "mdi:check-circle"
    lambda: "return id(signal_learned);"

Capturer la télécommande du ventilateur

La radio en écoute, un appui sur un bouton de la télécommande du ventilateur produit un dump raw dans les logs ESPHome qui ressemble à ceci (tronqué) :

[remote_receiver:...]: Received raw: [-1076, 320, -744, 718, -340, 337, ...]

Chaque valeur est une durée d’impulsion en microsecondes ; positif = mark (porteuse active), négatif = space (porteuse coupée). La télécommande envoie la même salve environ 5 fois par appui, avec ~10 ms d’écart entre les salves.

Le workflow de capture :

  1. Activer Learning Mode (ou appuyer sur le bouton sur la page de l’appareil).
  2. Appuyer sur un bouton de la télécommande physique — la première salve est enregistrée, les suivantes sont ignorées.
  3. Vérifier le capteur Last Received Signal et Replay Learned Signal pour confirmer que le ventilateur réagit.

Une fois la trame validée, je la « cuits » dans un bouton dédié pour qu’elle survive aux redémarrages et ne dépende plus de la variable globale volatile. Une salve capturée (90 impulsions ici) se rejoue directement avec remote_transmitter.transmit_raw :

button:
  - platform: template
    name: "Fan Replay"
    icon: "mdi:fan"
    on_press:
      - remote_transmitter.transmit_raw:
          code: [
              -1076,
              320,
              -744,
              718,
              -340,
              337,
              -718,
              341,
              -720,
              733,
              -318,
              342,
              # ... about 90 pulse values in total ...
            ]
          repeat:
            times: 5
            wait_time: 10ms

On appuie sur ce bouton depuis Home Assistant → le ventilateur réagit exactement comme si la télécommande physique avait été utilisée. Objectif n°1 accompli.

Résultat

Le CC1101 écoute tout ce qui passe en 433.92 MHz, rien n’empêche donc de gérer plus d’un appareil. J’ai d’abord branché le montage pour le ventilateur de plafond, puis je me suis rendu compte que la même installation pouvait absorber le second ventilateur, dans l’autre pièce :

  • Ventilateur n°1 et Ventilateur n°2 sont maintenant tous les deux contrôlables depuis Home Assistant via des boutons template dédiés qui rejouent chaque trame capturée (vitesses 1–6, off, lumière).

Le combo ESP32-S3 + CC1101 agit comme une passerelle 433 MHz unique et centralisée : un appareil, une antenne, deux ventilateurs, entièrement scriptable depuis des automatisations et des assistants vocaux.

Home Assistant

Une fois l’appareil flashé et connecté au Wi-Fi, il s’annonce tout seul à Home Assistant via l’API native ESPHome — aucune intégration manuelle nécessaire. Chaque button, switch, text_sensor et binary_sensor du YAML devient une entité automatiquement.

capture d'écran de l'ESP32 dans Home Assistant

Et comme les boutons sont de simples entités, ils peuvent être pilotés par des scènes, des planifications ou des assistants vocaux sans configuration supplémentaire.

automatiser le ventilateur dans Home Assistant

Bilan

  • La stack CC1101 + ESPHome est un très bon moyen d’intégrer des appareils 433 MHz « débiles ». C’est ouvert, flashable, et ça permet à la fois de capturer et d’émettre avec le même matériel.
  • Le choix des broches compte. Placer le chip select SPI sur la broche FSPICS0 matérielle (GPIO10) a fait la différence entre une radio morte et une radio fonctionnelle.
  • Le half-duplex, c’est LE piège. La radio doit être basculée explicitement entre RX et TX — une fois que on_transmit / on_complete sont correctement câblés, tout le reste « marche tout seul ».
  • L’IA en binôme. J’ai mené ce projet avec Hermes (Nous Research) — ça a comblé mon manque de connaissances en RF et ESPHome, et m’a débloqué à chaque étape. Ce genre de projet demandait autrefois une expérience embarquée préalable ; aujourd’hui, la curiosité suffit.

🤖 Cet article a été écrit avec un peu d'aide de l'IA

Un LLM m'a aidé à écrire cet article : rédaction, traduction et relecture. Les idées, le projet et le code sont les miens — donc non, ce n'est pas du 100% AI slop.