Sensori di distanza con ESP32-C6: VL53L1X ToF e HC-SR04

Sensori di distanza con ESP32-C6: VL53L1X ToF e HC-SR04

Misurare una distanza senza contatto serve in tantissimi progetti: rilevare una persona davanti a una porta, controllare il livello in un contenitore, fermare un robot prima di un ostacolo. Le due tecnologie più diffuse tra i maker sono il tempo di volo ottico (ToF) con un laser infrarosso e l’ultrasuono. In questo tutorial le usi entrambe sull’ESP32-C6 con ESP-IDF 5.4: il VL53L1X di STMicroelectronics su I2C e il classico HC-SR04, misurando l’impulso di eco con interrupt GPIO ed esp_timer. Alla fine hai un confronto concreto su portata, angolo di visione e materiali.

Cosa serve

Componente Esempio Note
Scheda ESP32-C6 ESP32-C6-DevKitC-1
Sensore ToF Breakout VL53L1X (Pololu, SparkFun, Adafruit o generico) I2C, indirizzo 0x29; molti breakout hanno regolatore e accettano 3,3-5 V
Sensore ultrasonico HC-SR04 Alimentazione 5 V, uscita ECHO a 5 V
Partitore per ECHO 1 kΩ + 2 kΩ (o 2,2 kΩ + 3,3 kΩ) Obbligatorio: porta ECHO da 5 V a circa 3,3 V
Varie Breadboard, cavetti, metro Il metro serve per verificare le misure

Come funziona

Tempo di volo ottico (VL53L1X)

Il VL53L1X contiene un emettitore laser VCSEL a 940 nm (classe 1, invisibile e sicuro per gli occhi) e una matrice di fotodiodi SPAD, capaci di rilevare singoli fotoni. Il chip emette impulsi di luce e misura direttamente il tempo che impiegano a tornare: a 1 m di distanza sono circa 6,7 ns tra andata e ritorno. Il risultato è una distanza in millimetri, indipendente (entro certi limiti) dal colore e dalla riflettività del bersaglio. Il campo visivo è di circa 27°, riducibile scegliendo una regione di interesse (ROI) della matrice. In modalità long ST dichiara fino a 4 m al buio con un bersaglio bianco; con luce ambiente forte la portata scende sensibilmente, perché il sole è pieno di infrarosso a 940 nm.

Ultrasuono (HC-SR04)

L’HC-SR04 emette una raffica di impulsi a 40 kHz quando riceve sul pin TRIG un impulso di almeno 10 µs. Poi alza il pin ECHO e lo riabbassa quando riceve l’eco: la durata dell’impulso ECHO è il tempo di andata e ritorno del suono. Con la velocità del suono c (circa 343 m/s a 20 °C, e cresce di circa 0,6 m/s per ogni grado):

d = t · c / 2        c ≈ 331,3 + 0,606 · T  [m/s]

Il fascio è largo (il datasheet indica circa 15°), la portata utile è da 2 cm a circa 4 m, e tra due misure servono almeno 60 ms perché gli echi della raffica precedente si esauriscano.

Confronto

Caratteristica VL53L1X HC-SR04
Portata tipica 4 cm – 4 m (long, al buio); circa 1,3 m in short 2 cm – 4 m
Angolo di visione circa 27°, riducibile con la ROI circa 15° secondo il datasheet, lobo reale più irregolare
Materiali difficili vetro e plexiglass trasparenti, superfici a specchio, nero opaco molto assorbente tessuti, gommapiuma, superfici inclinate che deviano l’eco
Ambiente soffre la luce solare diretta soffre vento, temperatura (velocità del suono), rumore ultrasonico
Interfaccia I2C, 3,3 V 2 GPIO, logica a 5 V
Frequenza di misura fino a decine di Hz circa 15 Hz massimo

Collegamenti

Pin sensore GPIO ESP32-C6 Note
VL53L1X VIN 3V3 Controlla il range accettato dal tuo breakout
VL53L1X GND GND
VL53L1X SDA GPIO6 Pull-up sul breakout nella maggior parte dei casi
VL53L1X SCL GPIO7
VL53L1X XSHUT, GPIO1 non collegati XSHUT ha il pull-up sul breakout; GPIO1 è l’interrupt “dato pronto”
HC-SR04 VCC 5V
HC-SR04 GND GND
HC-SR04 TRIG GPIO4 I 3,3 V del GPIO sono in genere sufficienti come livello alto
HC-SR04 ECHO GPIO5 tramite partitore 1 kΩ da ECHO al GPIO, 2 kΩ dal GPIO a GND

Attenzione al pin ECHO: l’HC-SR04 alimentato a 5 V restituisce un impulso a 5 V, e i GPIO dell’ESP32-C6 non sono tolleranti a 5 V. Con 1 kΩ e 2 kΩ ottieni 5 · 2/3 ≈ 3,33 V: al limite ma accettabile; con 2,2 kΩ e 3,3 kΩ scendi a 3,0 V, con margine. In alternativa usa un traslatore di livello o una variante a 3,3 V del sensore (per esempio HC-SR04P o RCWL-1601, da verificare sul datasheet del venditore).

La periferica dell’ESP32-C6

Per il VL53L1X usi il nuovo driver driver/i2c_master.h a 400 kHz. Il chip ha registri indirizzati a 16 bit, quindi ogni lettura è un i2c_master_transmit_receive() con due byte di indice seguiti dai dati. Il VL53L1X non ha un registro “start” semplice: all’accensione va caricato con un blocco di configurazione di 91 byte (registri 0x2D-0x87) fornito da ST nel suo Ultra Lite Driver (ULD, licenza BSD-3). Sul registry dei componenti Espressif esistono porting dell’ULD, ma al momento della stesura quello più completo richiede ESP-IDF 5.5 o successivo; per restare su ESP-IDF 5.4 senza dipendenze il codice implementa un driver minimo che replica esattamente la sequenza di VL53L1X_SensorInit() dell’ULD, con il blocco di configurazione copiato dal sorgente ST, e le quattro operazioni che servono: dato pronto, stato, distanza, clear dell’interrupt. Distance mode e timing budget restano quelli di default dell’ULD (long, 100 ms).

Per l’HC-SR04 usi driver/gpio.h con interrupt su entrambi i fronti di ECHO e esp_timer_get_time(), che restituisce un tempo in microsecondi a 64 bit. La latenza dell’interrupt (pochi microsecondi) corrisponde a meno di un millimetro, trascurabile rispetto alla precisione del sensore. Se vuoi una misura completamente hardware puoi usare la periferica RMT in ricezione (driver/rmt_rx.h), che registra la durata degli impulsi senza intervento della CPU: è l’approccio migliore quando hai molti sensori o carichi di interrupt pesanti.

Il codice

In main/CMakeLists.txt dichiara PRIV_REQUIRES esp_driver_i2c esp_driver_gpio esp_timer. Il main.c completo:

/*
 * Sensori di distanza con ESP32-C6 (ESP-IDF 5.4)
 * - VL53L1X time-of-flight su I2C: driver minimo basato sulla sequenza
 *   dell'Ultra Lite Driver (ULD) di ST, con il nuovo driver i2c_master
 * - HC-SR04 ultrasonico: impulso ECHO misurato con interrupt GPIO + esp_timer
 * www.webappportal.com
 */
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/i2c_master.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#include "esp_rom_sys.h"
#include "esp_log.h"

static const char *TAG = "distanza";

/* ---------- Pin ---------- */
#define I2C_SDA_GPIO        GPIO_NUM_6
#define I2C_SCL_GPIO        GPIO_NUM_7
#define HCSR04_TRIG_GPIO    GPIO_NUM_4
#define HCSR04_ECHO_GPIO    GPIO_NUM_5      /* tramite partitore 5 V -> 3,3 V! */

/* ---------- VL53L1X: registri (da VL53L1X_api.h dell'ULD ST) ---------- */
#define VL53L1X_ADDR                    0x29
#define REG_VHV_TIMEOUT_MACROP_LOOP     0x0008
#define REG_GPIO_HV_MUX_CTRL            0x0030
#define REG_GPIO_TIO_HV_STATUS          0x0031
#define REG_SYSTEM_INTERRUPT_CLEAR      0x0086
#define REG_SYSTEM_MODE_START           0x0087
#define REG_RESULT_RANGE_STATUS         0x0089
#define REG_RESULT_DISTANCE_MM          0x0096
#define REG_FIRMWARE_SYSTEM_STATUS      0x00E5
#define REG_IDENTIFICATION_MODEL_ID     0x010F

/* Configurazione di default 0x2D..0x87 copiata dall'ULD ST
 * (VL51L1X_DEFAULT_CONFIGURATION, licenza BSD-3). Non modificarla a mano:
 * i campi utente vanno cambiati con le funzioni dell'API ULD. */
static const uint8_t VL53L1X_DEFAULT_CONFIG[] = {
    0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x02, 0x08, 0x00, 0x08, /* 0x2D-0x36 */
    0x10, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x0F, /* 0x37-0x40 */
    0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0b, 0x00, 0x00, 0x02, /* 0x41-0x4A */
    0x0a, 0x21, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0xc8, /* 0x4B-0x54 */
    0x00, 0x00, 0x38, 0xff, 0x01, 0x00, 0x08, 0x00, 0x00, 0x01, /* 0x55-0x5E */
    0xcc, 0x0f, 0x01, 0xf1, 0x0d, 0x01, 0x68, 0x00, 0x80, 0x08, /* 0x5F-0x68 */
    0xb8, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x89, 0x00, 0x00, 0x00, /* 0x69-0x72 */
    0x00, 0x00, 0x00, 0x00, 0x01, 0x0f, 0x0d, 0x0e, 0x0e, 0x00, /* 0x73-0x7C */
    0x00, 0x02, 0xc7, 0xff, 0x9B, 0x00, 0x00, 0x00, 0x01, 0x00, /* 0x7D-0x86 */
    0x00                                                        /* 0x87 */
};

/* Conversione dello stato grezzo in codice ULD (0 = misura valida) */
static const uint8_t RANGE_STATUS_MAP[24] = {
    255, 255, 255, 5, 2, 4, 1, 7, 3, 0,
    255, 255, 9, 13, 255, 255, 255, 255, 10, 6,
    255, 255, 11, 12
};

static i2c_master_dev_handle_t tof;
static QueueHandle_t echo_queue;
static volatile int64_t echo_start_us;

/* ---------- Accesso ai registri a 16 bit del VL53L1X ---------- */
static esp_err_t tof_write8(uint16_t reg, uint8_t val)
{
    uint8_t buf[3] = { reg >> 8, reg & 0xFF, val };
    return i2c_master_transmit(tof, buf, sizeof(buf), 100);
}

static esp_err_t tof_read(uint16_t reg, uint8_t *data, size_t len)
{
    uint8_t idx[2] = { reg >> 8, reg & 0xFF };
    return i2c_master_transmit_receive(tof, idx, 2, data, len, 100);
}

static esp_err_t tof_read8(uint16_t reg, uint8_t *val)
{
    return tof_read(reg, val, 1);
}

static esp_err_t tof_read16(uint16_t reg, uint16_t *val)
{
    uint8_t b[2];
    esp_err_t err = tof_read(reg, b, 2);
    *val = (uint16_t)((b[0] << 8) | b[1]);
    return err;
}

static bool tof_data_ready(void)
{
    uint8_t mux = 0, status = 0;
    if (tof_read8(REG_GPIO_HV_MUX_CTRL, &mux) != ESP_OK ||
        tof_read8(REG_GPIO_TIO_HV_STATUS, &status) != ESP_OK) {
        return false;
    }
    uint8_t polarity = !((mux & 0x10) >> 4);
    return (status & 0x01) == polarity;
}

/* Stessa sequenza di VL53L1X_SensorInit() dell'ULD */
static esp_err_t tof_sensor_init(void)
{
    for (uint16_t reg = 0x2D; reg <= 0x87; reg++) {
        esp_err_t err = tof_write8(reg, VL53L1X_DEFAULT_CONFIG[reg - 0x2D]);
        if (err != ESP_OK) {
            return err;
        }
    }
    tof_write8(REG_SYSTEM_MODE_START, 0x40);        /* avvia una misura */
    int timeout = 1000;
    while (!tof_data_ready()) {
        if (--timeout == 0) {
            return ESP_ERR_TIMEOUT;
        }
        vTaskDelay(pdMS_TO_TICKS(1));
    }
    tof_write8(REG_SYSTEM_INTERRUPT_CLEAR, 0x01);
    tof_write8(REG_SYSTEM_MODE_START, 0x00);        /* stop */
    tof_write8(REG_VHV_TIMEOUT_MACROP_LOOP, 0x09);  /* two bounds VHV */
    return tof_write8(0x000B, 0x00);                /* VHV dalla temperatura precedente */
}

static esp_err_t tof_init(void)
{
    i2c_master_bus_config_t bus_cfg = {
        .i2c_port = -1,
        .sda_io_num = I2C_SDA_GPIO,
        .scl_io_num = I2C_SCL_GPIO,
        .clk_source = I2C_CLK_SRC_DEFAULT,
        .glitch_ignore_cnt = 7,
        .flags.enable_internal_pullup = true,
    };
    i2c_master_bus_handle_t bus;
    ESP_ERROR_CHECK(i2c_new_master_bus(&bus_cfg, &bus));

    i2c_device_config_t dev_cfg = {
        .dev_addr_length = I2C_ADDR_BIT_LEN_7,
        .device_address = VL53L1X_ADDR,
        .scl_speed_hz = 400000,
    };
    ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &dev_cfg, &tof));

    /* Attendi il boot del firmware interno */
    uint8_t booted = 0;
    for (int i = 0; i < 100 && !booted; i++) {
        tof_read8(REG_FIRMWARE_SYSTEM_STATUS, &booted);
        vTaskDelay(pdMS_TO_TICKS(2));
    }
    if (!booted) {
        return ESP_ERR_TIMEOUT;
    }

    uint16_t id = 0;
    ESP_ERROR_CHECK(tof_read16(REG_IDENTIFICATION_MODEL_ID, &id));
    ESP_LOGI(TAG, "VL53L1X model ID = 0x%04X (atteso 0xEACC)", id);
    if (id != 0xEACC) {
        return ESP_ERR_NOT_FOUND;
    }

    esp_err_t err = tof_sensor_init();
    if (err == ESP_OK) {
        err = tof_write8(REG_SYSTEM_MODE_START, 0x40);   /* ranging continuo */
    }
    return err;
}

static bool tof_get_distance(uint16_t *mm, uint8_t *range_status)
{
    if (!tof_data_ready()) {
        return false;
    }
    uint8_t raw_status = 0;
    tof_read8(REG_RESULT_RANGE_STATUS, &raw_status);
    raw_status &= 0x1F;
    *range_status = (raw_status < 24) ? RANGE_STATUS_MAP[raw_status] : 255;
    tof_read16(REG_RESULT_DISTANCE_MM, mm);
    tof_write8(REG_SYSTEM_INTERRUPT_CLEAR, 0x01);        /* pronto per la prossima */
    return true;
}

/* ---------- HC-SR04 ---------- */
static void IRAM_ATTR echo_isr(void *arg)
{
    int64_t now = esp_timer_get_time();
    if (gpio_get_level(HCSR04_ECHO_GPIO)) {
        echo_start_us = now;                              /* fronte di salita */
    } else if (echo_start_us > 0) {
        uint32_t width = (uint32_t)(now - echo_start_us); /* fronte di discesa */
        echo_start_us = 0;
        BaseType_t hp_woken = pdFALSE;
        xQueueSendFromISR(echo_queue, &width, &hp_woken);
        if (hp_woken) {
            portYIELD_FROM_ISR();
        }
    }
}

static void hcsr04_init(void)
{
    gpio_config_t trig = {
        .pin_bit_mask = 1ULL << HCSR04_TRIG_GPIO,
        .mode = GPIO_MODE_OUTPUT,
    };
    ESP_ERROR_CHECK(gpio_config(&trig));
    gpio_set_level(HCSR04_TRIG_GPIO, 0);

    gpio_config_t echo = {
        .pin_bit_mask = 1ULL << HCSR04_ECHO_GPIO,
        .mode = GPIO_MODE_INPUT,
        .intr_type = GPIO_INTR_ANYEDGE,
    };
    ESP_ERROR_CHECK(gpio_config(&echo));

    echo_queue = xQueueCreate(1, sizeof(uint32_t));
    ESP_ERROR_CHECK(gpio_install_isr_service(0));
    ESP_ERROR_CHECK(gpio_isr_handler_add(HCSR04_ECHO_GPIO, echo_isr, NULL));
}

/* Restituisce la distanza in cm, o un valore negativo se non c'e' eco */
static float hcsr04_measure_cm(float air_temp_c)
{
    uint32_t width_us;
    xQueueReset(echo_queue);
    echo_start_us = 0;

    gpio_set_level(HCSR04_TRIG_GPIO, 1);                 /* impulso di trigger >= 10 us */
    esp_rom_delay_us(10);
    gpio_set_level(HCSR04_TRIG_GPIO, 0);

    if (xQueueReceive(echo_queue, &width_us, pdMS_TO_TICKS(60)) != pdTRUE) {
        return -1.0f;
    }
    /* Velocita' del suono in aria (m/s), approssimazione lineare */
    float c = 331.3f + 0.606f * air_temp_c;
    /* andata e ritorno: d = t * c / 2 ; us * m/s -> cm con fattore 1e-4 */
    return (float)width_us * c * 1e-4f / 2.0f;
}

void app_main(void)
{
    esp_err_t err = tof_init();
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "VL53L1X non inizializzato: %s", esp_err_to_name(err));
    }
    hcsr04_init();

    while (1) {
        uint16_t mm;
        uint8_t st;
        if (err == ESP_OK && tof_get_distance(&mm, &st)) {
            if (st == 0) {
                ESP_LOGI(TAG, "VL53L1X: %u mm", mm);
            } else {
                ESP_LOGW(TAG, "VL53L1X: %u mm (range status %u, misura non valida)", mm, st);
            }
        }

        float cm = hcsr04_measure_cm(20.0f);
        if (cm < 0.0f) {
            ESP_LOGW(TAG, "HC-SR04: nessuna eco");
        } else {
            ESP_LOGI(TAG, "HC-SR04: %.1f cm", cm);
        }
        vTaskDelay(pdMS_TO_TICKS(200));     /* HC-SR04: almeno 60 ms tra le misure */
    }
}

I punti chiave:

  • tof_write8() e tof_read() mandano l’indice di registro a 16 bit, byte alto per primo, come richiesto dal VL53L1X.
  • tof_init() attende il boot del firmware interno (registro 0x00E5 diverso da zero), verifica il model ID 0xEACC e poi chiama tof_sensor_init().
  • tof_sensor_init() scrive i 91 byte di default, esegue una misura a vuoto e poi imposta i due registri VHV, esattamente come l’ULD. Non modificare a mano l’array: i campi configurabili (soglie, ROI, periodo di misura) vanno cambiati con le funzioni dell’API ST, che scrivono i registri con i giusti formati.
  • tof_data_ready() confronta il bit 0 di GPIO__TIO_HV_STATUS con la polarità dell’interrupt, come VL53L1X_CheckForDataReady().
  • tof_get_distance() legge lo stato della misura e lo converte con la stessa tabella dell’ULD: 0 significa misura valida; 1, 2, 4 e 7 indicano rispettivamente errore sigma, segnale troppo debole, fuori limite e wrap-around. Una distanza con stato diverso da 0 va scartata.
  • echo_isr() salva il tempo del fronte di salita e, sul fronte di discesa, mette in coda la durata. hcsr04_measure_cm() genera il trigger con esp_rom_delay_us(10), aspetta fino a 60 ms e applica la velocità del suono compensata in temperatura: se hai un sensore di temperatura, passa il valore reale al posto di 20 °C.

Prova e risultati attesi

Metti entrambi i sensori affiancati davanti a un muro a circa 50 cm:

I (330) distanza: VL53L1X model ID = 0xEACC (atteso 0xEACC)
I (452) distanza: VL53L1X: 503 mm
I (455) distanza: HC-SR04: 50.4 cm
I (662) distanza: VL53L1X: 501 mm
I (665) distanza: HC-SR04: 50.3 cm
W (872) distanza: VL53L1X: 3867 mm (range status 2, misura non valida)
W (935) distanza: HC-SR04: nessuna eco

Le ultime due righe sono quello che vedi puntando i sensori verso il vuoto o verso una finestra: il VL53L1X restituisce comunque un numero, ma lo stato 2 (segnale debole) ti dice che non è affidabile; l’HC-SR04 non riceve eco entro 60 ms. Prova anche un maglione (l’ultrasuono lo “perde”) e una lastra di vetro (il laser la attraversa o misura riflessi spuri).

Problemi comuni

  • Model ID diverso da 0xEACC o errore I2C: controlla alimentazione e cablaggio. Se il breakout ha il pin XSHUT e lo hai collegato a un GPIO, deve essere alto. Un VL53L0X (sensore precedente, diverso) risponde allo stesso indirizzo ma non a questo codice.
  • Distanze del VL53L1X più corte del reale: una finestrella protettiva o un vetro davanti al sensore crea crosstalk. L’ULD ha funzioni di calibrazione di offset e crosstalk apposta.
  • Portata del VL53L1X molto ridotta all’aperto: luce solare. Passa a distance mode short e riduci la ROI, oppure usa l’ultrasuono.
  • HC-SR04 sempre “nessuna eco”: ECHO non arriva al GPIO (partitore sbagliato) o il sensore non è alimentato a 5 V.
  • HC-SR04 con letture casuali e brevi: echi dal bordo del tavolo o da ostacoli laterali dentro il fascio. Alza il sensore e allontanalo dagli oggetti vicini.
  • Il GPIO5 si è danneggiato: hai collegato ECHO senza partitore. Non farlo.

Va oltre

  • Usa il pin GPIO1 del VL53L1X come interrupt invece del polling, e il pin XSHUT per mettere più sensori sullo stesso bus cambiando indirizzo a uno alla volta.
  • Porta nel progetto l’intero ULD di ST se ti servono ROI, soglie di distanza e calibrazioni.
  • Un sensore di distanza puntato su un varco è un ottimo rilevatore di passaggio per l’antifurto; un ultrasonico impermeabile rivolto verso il basso misura il livello di una cisterna.
  • Invia le misure via MQTT come negli altri progetti di domotica.
  • Altri tutorial sulla famiglia ESP32: sezione ESP32.

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