Telecomando IR universale con ESP32-C6: RMT, NEC e learning

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_ns attiva il filtro hardware (impulsi sotto 1,25 µs sono disturbi); signal_range_max_ns dice quando il frame è finito (30 ms senza variazioni). Con invert_in invertiamo 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 con rmt_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_ns fino 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.

Riguardo

Imprenditore, maker, MBSE enthusiast e co-autore di Web App Portal. Collegati su LinkedIn.