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()etof_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 ID0xEACCe poi chiamatof_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 diGPIO__TIO_HV_STATUScon la polarità dell’interrupt, comeVL53L1X_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 conesp_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.
