Piloter un ventilateur de plafond depuis Home Assistant avec un CC1101 et un ESP32-S3
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.

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.

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 :
- Les ponts RF BroadLink RM4 / Tuya — ça fonctionne, mais ce sont des boîtes noires fermées qui dépendent d’un cloud tiers.
- Sonoff RF Bridge — flashable avec ESPHome, mais limité à un seul protocole et à l’ASK/OOK en 433 MHz uniquement.
- 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 :
| Pin | Nom | Direction | Rôle |
|---|---|---|---|
| 1 | GND | - | Masse |
| 2 | VCC | in | Alimentation, 1,8–3,6 V (3,3 V) |
| 3 | GDO0 | out | Sortie numérique (TX) |
| 4 | CSN | in | Chip select SPI |
| 5 | SCK | in | Horloge SPI |
| 6 | MOSI | in | Donnée SPI (entrée) |
| 7 | MISO/GDO1 | out | Donnée SPI (sortie) |
| 8 | GDO2 | out | Deuxiè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 CC1101 | GPIO ESP32-S3 | Fonction |
|---|---|---|
| 1 (GND) | GND | Masse |
| 2 (VCC) | 3V3 | Alimentation |
| 3 (GDO0) | GPIO7 | Donnée TX |
| 4 (CSN) | GPIO10 | SPI CS0 |
| 5 (SCK) | GPIO12 | Horloge SPI |
| 6 (MOSI) | GPIO11 | SPI MOSI |
| 7 (MISO) | GPIO13 | SPI MISO |
| 8 (GDO2) | GPIO21 | Donné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 Pronto006D(= IR 38 kHz). Restreindredump: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 :
- Activer Learning Mode (ou appuyer sur le bouton sur la page de l’appareil).
- Appuyer sur un bouton de la télécommande physique — la première salve est enregistrée, les suivantes sont ignorées.
- 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.

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.

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_completesont 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.