Telecomando IR universale con ESP32-C6: RMT, NEC e learning
Televisore, decoder, condizionatore, ventilatore, impianto audio: quasi tutti si comandano ancora con un telecomando a infrarossi. Un ESP32-C6 con un ricevitore IR e un LED IR diventa un telecomando universale che puoi comandare da rete: impara i codici dei telecomandi originali e li riproduce quando serve. In questo articolo usiamo la periferica RMT di ESP-IDF 5.4 sia per ricevere (driver/rmt_rx.h) sia per trasmettere con portante a 38 kHz (driver/rmt_tx.h), scriviamo un codificatore e decodificatore per il protocollo NEC e una modalità learning che registra e riproduce frame grezzi, necessaria per i condizionatori.
Cosa serve
| Componente | Esempio | Note |
|---|---|---|
| Scheda di sviluppo | ESP32-C6-DevKitC-1 | Logica a 3,3 V |
| Ricevitore IR | Vishay TSOP38238 (38 kHz) | Demodula la portante e restituisce un segnale logico attivo basso; ordine dei pin dal datasheet |
| LED IR | LED a 940 nm, per esempio Vishay TSAL6200 | Corrente di picco ammessa dal datasheet del LED |
| Transistor | NPN tipo 2N2222A o BC337 | Il GPIO da solo non fornisce abbastanza corrente per una buona portata |
| Resistenze | 1 kΩ (base), 10 kΩ (base-massa), resistenza serie del LED | Il valore serie si calcola dalla corrente voluta, vedi Collegamenti |
| Filtro alimentazione ricevitore | Resistenza + condensatore | Valori consigliati nel datasheet del TSOP |
| Due pulsanti | Pulsanti tattili | Per provare apprendimento e invio |
Come funziona
Un telecomando IR non accende il LED in modo continuo: lo fa lampeggiare a una frequenza portante, tipicamente 38 kHz. I dati sono codificati nella durata dei “burst” di portante (mark) e delle pause (space). Il ricevitore integrato come il TSOP38238 contiene fotodiodo, amplificatore, filtro passa-banda e demodulatore: la sua uscita è bassa durante un burst e alta durante una pausa. Così l’ambiente, con la luce del sole e delle lampade, disturba molto meno.
Il protocollo NEC è uno dei più diffusi. Un frame è fatto da un burst iniziale di 9 ms, una pausa di 4,5 ms e 32 bit trasmessi dal meno significativo: indirizzo, indirizzo negato, comando, comando negato (nella variante estesa l’indirizzo occupa tutti i 16 bit). Ogni bit inizia con un burst di circa 560 µs; la pausa che segue vale circa 560 µs per lo 0 e 1690 µs per l’1. Se tieni premuto il tasto, il telecomando invia un codice di ripetizione corto (9 ms di burst e 2,25 ms di pausa) invece di ripetere il frame.
I condizionatori funzionano in modo molto diverso. Il telecomando del climatizzatore ha un display e “sa” lo stato completo: modalità, temperatura, ventola, alette, timer. A ogni pressione di un tasto invia l’intero stato in un frame lungo (spesso più di cento bit, a volte diviso in più blocchi, con checksum), non il comando “temperatura +1”. Ne derivano due conseguenze pratiche. La prima: registrare il tasto “+” non serve, perché riprodurlo rimanda sempre lo stesso stato. La seconda: per comandare un condizionatore devi o imparare un frame per ogni stato che ti interessa (per esempio “freddo 24 °C ventola auto”, “caldo 21 °C”, “spento”) oppure usare una libreria di protocolli che conosca il formato di quella marca e generi il frame a partire dallo stato desiderato.
La modalità learning che realizziamo registra il frame come sequenza di durate (in microsecondi) e la ritrasmette identica. Se il frame è NEC, lo decodifica e lo memorizza come indirizzo e comando, così la trasmissione viene rigenerata con tempi esatti invece che con quelli misurati.
Collegamenti
| Segnale | GPIO ESP32-C6 | Note |
|---|---|---|
| TSOP38238 OUT | GPIO 10 | Ingresso RMT RX; uscita attiva bassa |
| TSOP38238 VS | 3,3 V | Alimentandolo a 3,3 V l’uscita è compatibile con l’ESP32-C6; aggiungi il filtro RC del datasheet |
| TSOP38238 GND | GND | |
| Base del transistor | GPIO 11 | Tramite 1 kΩ; 10 kΩ tra base e massa tengono il LED spento durante il reset |
| LED IR | Anodo al 5 V tramite resistenza serie, catodo al collettore | Emettitore del transistor a GND |
| Pulsante A / B | GPIO 18 / GPIO 19 | Verso GND, pull-up interno |
La resistenza serie del LED si calcola con R = (5 V − Vf − Vce(sat)) / I. Con una tensione diretta di circa 1,4 V, una Vce di saturazione di circa 0,2 V e una corrente di 100 mA ottieni circa 33 Ω. Il LED è acceso solo durante i burst e con un duty della portante del 33%, quindi la corrente media è molto più bassa; resta però il vincolo della corrente di picco e della potenza, da verificare nel datasheet del LED. Il TSOP e il LED non devono “vedersi” direttamente, o il ricevitore catturerà le tue stesse trasmissioni (il firmware comunque le ignora per 100 ms dopo ogni invio).
La periferica dell’ESP32-C6
L’RMT (Remote Control Transceiver) è nato proprio per questo lavoro. L’ESP32-C6 ha 4 canali: 2 TX e 2 RX, ciascuno con 48 simboli di memoria. Un simbolo (rmt_symbol_word_t) contiene due coppie livello/durata, cioè esattamente un burst e la pausa che segue.
-
RX: il canale campiona il pin e registra le durate con una risoluzione di 1 µs.
signal_range_min_nsattiva il filtro hardware (impulsi sotto 1,25 µs sono disturbi);signal_range_max_nsdice quando il frame è finito (30 ms senza variazioni). Coninvert_ininvertiamo l’ingresso: così i burst del TSOP attivo basso diventano livello 1, esattamente come in trasmissione, e un frame registrato si ritrasmette senza conversioni. Sull’ESP32-C6 il driver usa la memoria del canale in ping-pong, quindi puoi ricevere frame molto più lunghi di 48 simboli purché il buffer utente sia abbastanza grande (qui 512 simboli). -
TX: con
rmt_apply_carrier()l’hardware modula a 38 kHz con duty del 33% tutti i tratti a livello 1. Tu fornisci solo le durate di burst e pause.
Per la trasmissione usiamo il copy encoder: sia il frame NEC sia quello grezzo sono array di simboli già pronti. ESP-IDF ha anche un esempio con un encoder NEC dedicato che genera i simboli al volo; per un telecomando universale la strada dell’array è più semplice e unifica i due casi.
Il codice
Progetto verificato con idf.py build su ESP-IDF 5.4, target esp32c6. Ecco main/main.c:
/*
* Telecomando universale IR su ESP32-C6 (ESP-IDF 5.4)
* - ricezione con TSOP38238 e RMT RX (durate degli impulsi in microsecondi)
* - trasmissione con LED IR + transistor e RMT TX con portante 38 kHz
* - codifica/decodifica protocollo NEC
* - modalita' "learning": registra il frame grezzo e lo riproduce
* www.webappportal.com
*/
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/rmt_tx.h"
#include "driver/rmt_rx.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#include "esp_log.h"
static const char *TAG = "ir";
#define GPIO_IR_RX 10 /* uscita del TSOP38238 */
#define GPIO_IR_TX 11 /* base del transistor che pilota il LED IR */
#define GPIO_BTN_A 18
#define GPIO_BTN_B 19
#define IR_RES_HZ 1000000 /* 1 tick = 1 us */
#define IR_CARRIER_HZ 38000
#define MAX_SYMBOLS 512 /* basta anche per molti frame di condizionatori */
#define NUM_SLOTS 2
#define LONG_PRESS_US 1000000
/* Tempi NEC in microsecondi */
#define NEC_LEAD_MARK 9000
#define NEC_LEAD_SPACE 4500
#define NEC_REPEAT_SPACE 2250
#define NEC_BIT_MARK 560
#define NEC_ZERO_SPACE 560
#define NEC_ONE_SPACE 1690
typedef struct {
bool valid;
bool is_nec;
uint16_t address;
uint16_t command;
size_t len;
rmt_symbol_word_t sym[MAX_SYMBOLS];
} ir_slot_t;
static rmt_channel_handle_t s_tx, s_rx;
static rmt_encoder_handle_t s_copy_enc;
static QueueHandle_t s_rx_q;
static rmt_symbol_word_t s_rx_buf[MAX_SYMBOLS];
static ir_slot_t s_slots[NUM_SLOTS];
static int s_learn_slot = -1; /* -1 = nessun apprendimento in corso */
static int64_t s_ignore_until_us; /* ignora l'eco dei nostri stessi invii */
static const rmt_receive_config_t s_rx_cfg = {
.signal_range_min_ns = 1250, /* impulsi piu' corti = disturbi */
.signal_range_max_ns = 30000000, /* 30 ms di silenzio = fine del frame */
};
static bool IRAM_ATTR rx_done_cb(rmt_channel_handle_t ch, const rmt_rx_done_event_data_t *ed, void *ctx)
{
BaseType_t woken = pdFALSE;
size_t n = ed->num_symbols;
xQueueSendFromISR((QueueHandle_t)ctx, &n, &woken);
return woken == pdTRUE;
}
static bool near(uint32_t d, uint32_t target)
{
return d > target * 7 / 10 && d < target * 13 / 10; /* tolleranza +-30% */
}
/* Restituisce 1 = frame NEC, 2 = codice di ripetizione, 0 = altro protocollo */
static int nec_decode(const rmt_symbol_word_t *s, size_t n, uint16_t *addr, uint16_t *cmd)
{
if (n >= 2 && near(s[0].duration0, NEC_LEAD_MARK) && near(s[0].duration1, NEC_REPEAT_SPACE)) {
return 2;
}
if (n < 33 || !near(s[0].duration0, NEC_LEAD_MARK) || !near(s[0].duration1, NEC_LEAD_SPACE)) {
return 0;
}
uint32_t data = 0;
for (int i = 0; i < 32; i++) {
const rmt_symbol_word_t *b = &s[1 + i];
if (!near(b->duration0, NEC_BIT_MARK)) {
return 0;
}
if (near(b->duration1, NEC_ONE_SPACE)) {
data |= 1UL << i; /* NEC trasmette LSB first */
} else if (!near(b->duration1, NEC_ZERO_SPACE)) {
return 0;
}
}
*addr = data & 0xFFFF;
*cmd = data >> 16;
return 1;
}
static size_t nec_encode(uint16_t addr, uint16_t cmd, rmt_symbol_word_t *out)
{
uint32_t data = ((uint32_t)cmd << 16) | addr;
size_t n = 0;
out[n++] = (rmt_symbol_word_t){ .level0 = 1, .duration0 = NEC_LEAD_MARK, .level1 = 0, .duration1 = NEC_LEAD_SPACE };
for (int i = 0; i < 32; i++) {
bool one = data & (1UL << i);
out[n++] = (rmt_symbol_word_t){ .level0 = 1, .duration0 = NEC_BIT_MARK,
.level1 = 0, .duration1 = one ? NEC_ONE_SPACE : NEC_ZERO_SPACE };
}
out[n++] = (rmt_symbol_word_t){ .level0 = 1, .duration0 = NEC_BIT_MARK, .level1 = 0, .duration1 = 1000 };
return n;
}
static void ir_send(const rmt_symbol_word_t *sym, size_t n)
{
rmt_transmit_config_t cfg = { .loop_count = 0 };
ESP_ERROR_CHECK(rmt_transmit(s_tx, s_copy_enc, sym, n * sizeof(rmt_symbol_word_t), &cfg));
ESP_ERROR_CHECK(rmt_tx_wait_all_done(s_tx, -1));
s_ignore_until_us = esp_timer_get_time() + 100000;
}
static void slot_play(int i)
{
ir_slot_t *sl = &s_slots[i];
if (!sl->valid) {
ESP_LOGW(TAG, "Slot %d vuoto: tieni premuto il tasto 1 s per registrarlo", i);
return;
}
if (sl->is_nec) {
rmt_symbol_word_t frame[34];
size_t n = nec_encode(sl->address, sl->command, frame);
ir_send(frame, n);
ESP_LOGI(TAG, "Slot %d: inviato NEC addr=0x%04X cmd=0x%04X", i, sl->address, sl->command);
} else {
ir_send(sl->sym, sl->len);
ESP_LOGI(TAG, "Slot %d: inviato frame grezzo di %u simboli", i, (unsigned)sl->len);
}
}
static void process_frame(size_t n)
{
if (esp_timer_get_time() < s_ignore_until_us || n == 0) {
return;
}
uint16_t addr = 0, cmd = 0;
int nec = nec_decode(s_rx_buf, n, &addr, &cmd);
if (nec == 1) {
ESP_LOGI(TAG, "RX NEC addr=0x%04X cmd=0x%04X", addr, cmd);
} else if (nec == 2) {
ESP_LOGI(TAG, "RX NEC ripetizione");
return; /* non si impara un codice di ripetizione */
} else {
ESP_LOGI(TAG, "RX sconosciuto: %u simboli, primo impulso %u/%u us", (unsigned)n,
(unsigned)s_rx_buf[0].duration0, (unsigned)s_rx_buf[0].duration1);
}
if (s_learn_slot >= 0) {
ir_slot_t *sl = &s_slots[s_learn_slot];
memcpy(sl->sym, s_rx_buf, n * sizeof(rmt_symbol_word_t));
sl->len = n;
/* il RX chiude il frame con durata 0: in TX la sostituiamo con 1 ms di pausa */
if (sl->sym[n - 1].duration1 == 0) {
sl->sym[n - 1].duration1 = 1000;
}
sl->is_nec = (nec == 1);
sl->address = addr;
sl->command = cmd;
sl->valid = true;
ESP_LOGI(TAG, "Slot %d memorizzato (%s)", s_learn_slot, sl->is_nec ? "NEC" : "grezzo");
s_learn_slot = -1;
}
}
typedef struct {
int gpio;
int integ;
bool pressed;
bool long_sent;
int64_t t0;
} button_t;
/* 0 = niente, 1 = pressione breve, 2 = pressione lunga */
static int button_poll(button_t *b)
{
bool raw = gpio_get_level(b->gpio) == 0;
if (raw && b->integ < 3) {
b->integ++;
} else if (!raw && b->integ > 0) {
b->integ--;
}
int64_t now = esp_timer_get_time();
if (!b->pressed && b->integ == 3) {
b->pressed = true;
b->long_sent = false;
b->t0 = now;
} else if (b->pressed && b->integ == 0) {
b->pressed = false;
return b->long_sent ? 0 : 1;
}
if (b->pressed && !b->long_sent && now - b->t0 >= LONG_PRESS_US) {
b->long_sent = true;
return 2;
}
return 0;
}
void app_main(void)
{
/* TX: LED IR pilotato da transistor NPN, livello alto = LED acceso con portante */
rmt_tx_channel_config_t tx_cfg = {
.gpio_num = GPIO_IR_TX,
.clk_src = RMT_CLK_SRC_DEFAULT,
.resolution_hz = IR_RES_HZ,
.mem_block_symbols = 48,
.trans_queue_depth = 4,
};
ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_cfg, &s_tx));
rmt_carrier_config_t carrier_cfg = {
.frequency_hz = IR_CARRIER_HZ,
.duty_cycle = 0.33f,
};
ESP_ERROR_CHECK(rmt_apply_carrier(s_tx, &carrier_cfg));
rmt_copy_encoder_config_t enc_cfg = {};
ESP_ERROR_CHECK(rmt_new_copy_encoder(&enc_cfg, &s_copy_enc));
ESP_ERROR_CHECK(rmt_enable(s_tx));
/* RX: il TSOP e' attivo basso, invert_in rende gli impulsi livello 1 come in TX */
rmt_rx_channel_config_t rx_cfg = {
.gpio_num = GPIO_IR_RX,
.clk_src = RMT_CLK_SRC_DEFAULT,
.resolution_hz = IR_RES_HZ,
.mem_block_symbols = 48,
.flags.invert_in = true,
};
ESP_ERROR_CHECK(rmt_new_rx_channel(&rx_cfg, &s_rx));
s_rx_q = xQueueCreate(4, sizeof(size_t));
rmt_rx_event_callbacks_t cbs = { .on_recv_done = rx_done_cb };
ESP_ERROR_CHECK(rmt_rx_register_event_callbacks(s_rx, &cbs, s_rx_q));
ESP_ERROR_CHECK(rmt_enable(s_rx));
ESP_ERROR_CHECK(rmt_receive(s_rx, s_rx_buf, sizeof(s_rx_buf), &s_rx_cfg));
gpio_config_t btn_cfg = {
.pin_bit_mask = (1ULL << GPIO_BTN_A) | (1ULL << GPIO_BTN_B),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
ESP_ERROR_CHECK(gpio_config(&btn_cfg));
button_t btn[NUM_SLOTS] = { { .gpio = GPIO_BTN_A }, { .gpio = GPIO_BTN_B } };
ESP_LOGI(TAG, "Pronto. Tasto breve = invia slot, tasto lungo = impara slot");
while (1) {
size_t n;
if (xQueueReceive(s_rx_q, &n, pdMS_TO_TICKS(10)) == pdTRUE) {
process_frame(n);
ESP_ERROR_CHECK(rmt_receive(s_rx, s_rx_buf, sizeof(s_rx_buf), &s_rx_cfg));
}
for (int i = 0; i < NUM_SLOTS; i++) {
int ev = button_poll(&btn[i]);
if (ev == 1) {
slot_play(i);
} else if (ev == 2) {
s_learn_slot = i;
ESP_LOGI(TAG, "Slot %d: punta il telecomando originale e premi il tasto", i);
}
}
}
}
I punti chiave:
-
rx_done_cb(): callback in contesto di interrupt chiamata a fine frame. Passa solo il numero di simboli a una coda; l’elaborazione avviene nel task principale, che poi riarma la ricezione conrmt_receive(). -
nec_decode(): controlla il burst iniziale con una tolleranza del ±30% (le uscite dei ricevitori deformano un po’ la durata degli impulsi), distingue il codice di ripetizione e ricostruisce i 32 bit dal meno significativo. -
nec_encode(): l’operazione inversa, 34 simboli: intestazione, 32 bit, burst finale. -
process_frame(): stampa ciò che riceve e, se un apprendimento è in corso, copia il frame nello slot. Il ricevitore chiude l’ultimo simbolo con durata zero, che in trasmissione verrebbe interpretata come fine dati: la sostituiamo con 1 ms di pausa. - Pulsanti: pressione breve = invia lo slot; pressione lunga (1 s) = prossimo frame ricevuto va in quello slot.
Prova e risultati attesi
Punta un telecomando al ricevitore e premi qualche tasto. Poi tieni premuto il pulsante A per un secondo, premi un tasto del telecomando originale, e con una pressione breve di A ritrasmettilo verso il dispositivo:
I (321) ir: Pronto. Tasto breve = invia slot, tasto lungo = impara slot I (5210) ir: RX NEC addr=0xFB04 cmd=0xF708 I (5320) ir: RX NEC ripetizione I (9800) ir: Slot 0: punta il telecomando originale e premi il tasto I (11250) ir: RX NEC addr=0xFB04 cmd=0xF708 I (11250) ir: Slot 0 memorizzato (NEC) I (14010) ir: Slot 0: inviato NEC addr=0xFB04 cmd=0xF708 I (17500) ir: Slot 1: punta il telecomando originale e premi il tasto I (19840) ir: RX sconosciuto: 228 simboli, primo impulso 3380/1690 us I (19840) ir: Slot 1 memorizzato (grezzo) I (23020) ir: Slot 1: inviato frame grezzo di 228 simboli
Nel frame NEC dell’esempio l’indirizzo è 0x04 e il comando 0x08: nei 16 bit stampati trovi il byte originale e, nel byte alto, la sua negazione (0xFB e 0xF7). Il secondo frame, lungo 228 simboli, è tipico di un condizionatore: non è NEC e viene memorizzato come sequenza grezza. Per verificare la trasmissione senza oscilloscopio, guarda il LED IR con la fotocamera di uno smartphone: molte fotocamere mostrano la luce infrarossa come un lampo violaceo.
Problemi comuni
- Nessuna ricezione: controlla l’ordine dei pin del ricevitore (cambia tra le famiglie) e che la frequenza del telecomando sia compatibile con un ricevitore a 38 kHz. Alcuni apparecchi usano 36 o 40 kHz: il TSOP a 38 kHz di solito li riceve lo stesso, ma a distanza ridotta.
-
Il frame di un condizionatore viene spezzato in due: alcuni protocolli hanno pause lunghe tra i blocchi. Aumenta
signal_range_max_nsfino al limite hardware (32767 tick, cioè circa 32 ms a 1 MHz); oltre, riduci la risoluzione del canale RX, per esempio a 500 kHz, che raddoppia la pausa massima con una precisione di 2 µs, ancora sufficiente per l’IR. - Il dispositivo non risponde alla ritrasmissione: portata insufficiente (corrente del LED troppo bassa, LED non puntato) oppure il protocollo richiede che il frame sia ripetuto. Alcuni apparecchi vogliono il frame inviato due o tre volte.
- Il condizionatore esegue comandi “sbagliati”: stai riproducendo uno stato e non un comando. Registra un frame per ogni stato completo che ti serve.
- Codici sporadici quando nessuno preme nulla: luce solare diretta o lampade fluorescenti; il filtro del TSOP aiuta ma non fa miracoli. Scherma il ricevitore e verifica il filtro sull’alimentazione.
Va oltre
Il passo successivo è salvare gli slot in NVS, così sopravvivono al riavvio, e aumentarne il numero associando a ciascuno un nome. Poi il collegamento alla domotica: esporre i comandi via MQTT a Home Assistant, oppure trasformare il nodo in un dispositivo Matter sfruttando la radio Wi-Fi 6 e Thread dell’ESP32-C6. Per i condizionatori conviene appoggiarsi a una libreria di protocolli che generi il frame dallo stato (modalità, temperatura, ventola), come fanno alcune integrazioni IR per Home Assistant; così puoi impostare la temperatura in base ai dati della stazione meteo o spegnere tutto quando l’antifurto viene inserito e la casa è vuota. Sono tutti argomenti che vanno oltre questo articolo, ma la base hardware e la periferica RMT restano esattamente queste. Altri progetti con la famiglia ESP32 li trovi nella sezione ESP32.
