Sensore di gesture APDS-9960 con ESP32-C6 ed ESP-IDF 5.4
Accendere una luce, scorrere un menu o cambiare modalità con un gesto della mano, senza toccare nulla: è comodo in cucina, in bagno o su un dispositivo che deve restare pulito. In questo tutorial colleghi all’ESP32-C6 un APDS-9960 di Broadcom, un piccolo sensore I2C che integra prossimità, luce ambiente, colore e un vero e proprio motore per il riconoscimento dei gesti. Scrivi un driver minimo con il nuovo driver I2C master di ESP-IDF 5.4, gestisci il pin di interrupt su un GPIO, scarichi la FIFO dei dati e decodifichi i quattro gesti di base: su, giù, sinistra e destra.
Cosa serve
| Componente | Esempio | Note |
|---|---|---|
| Scheda ESP32-C6 | ESP32-C6-DevKitC-1 | |
| Sensore di gesture | Breakout APDS-9960 (SparkFun, Adafruit o generico) | Alimentazione 3,3 V (il chip accetta 2,4-3,6 V), I2C a 0x39 |
| Resistenze di pull-up | 2 × 4,7 kΩ (SDA, SCL), 1 × 10 kΩ (INT) | Solo se il breakout non le ha già |
| Varie | Breadboard, cavetti |
Come funziona
L’APDS-9960 contiene un LED infrarosso con il suo driver e quattro fotodiodi disposti a croce, chiamati U (up), D (down), L (left) e R (right). Il LED emette brevi impulsi; la mano che passa sopra il sensore riflette l’infrarosso, e ogni fotodiodo riceve una quantità di luce che dipende dalla posizione della mano.
Il chip funziona come una macchina a stati. Nel ciclo normale misura la prossimità (un valore 0-255 che cresce quando qualcosa si avvicina). Quando la prossimità supera la soglia GPENTH, entra nel motore gesture: campiona in continuo i quattro fotodiodi e scrive ogni campione (un dataset di 4 byte: U, D, L, R) in una FIFO interna da 32 posizioni. Quando i valori scendono sotto la soglia di uscita GEXTH, il motore si ferma e il chip torna alla prossimità.
La direzione del gesto non la calcola il chip: la ricavi tu dai dati. L’idea è semplice. Se la mano si muove dall’alto verso il basso, all’inizio del gesto il fotodiodo U vede più luce di D, alla fine il contrario. Calcoli quindi per il primo e l’ultimo dataset significativo i rapporti normalizzati:
ud = (U − D) · 100 / (U + D) lr = (L − R) · 100 / (L + R)
e guardi come cambiano: la variazione più ampia (ud o lr) dice l’asse, il segno dice il verso. È lo stesso algoritmo, semplificato, della libreria open source di SparkFun. Quale sia “su” dipende da come monti il sensore: se le direzioni risultano invertite o ruotate, basta scambiarle nel codice.
Un’alternativa è il PAJ7620 di PixArt, che riconosce internamente una decina di gesti (compresi avanti/indietro e rotazioni) e restituisce direttamente il codice del gesto. È più comodo, ma ti dà meno controllo sui dati grezzi e ha una mappa registri a banchi più complessa; verifica sul datasheet indirizzo e sequenza di inizializzazione.
Collegamenti
| Pin sensore | GPIO ESP32-C6 | Note |
|---|---|---|
| VCC (o VIN) | 3V3 | Non 5 V, a meno che il breakout non abbia un regolatore |
| GND | GND | |
| SDA | GPIO6 | Pull-up 4,7 kΩ verso 3,3 V |
| SCL | GPIO7 | Pull-up 4,7 kΩ verso 3,3 V |
| INT | GPIO5 | Open-drain, attivo basso: serve un pull-up (interno o esterno) |
| VL (se presente) | vedi note | Alimentazione separata del LED IR su alcuni breakout |
Alcuni breakout (per esempio quello SparkFun) hanno un pin VL per alimentare il LED infrarosso separatamente, con un ponticello che lo collega a VCC. Se il ponticello è aperto e VL non è collegato, il LED non si accende: prossimità e gesture restano a zero anche se l’I2C funziona. Il codice abilita il pull-up interno su INT; per cavi più lunghi di qualche centimetro aggiungi un 10 kΩ esterno.
La periferica dell’ESP32-C6
La comunicazione passa per driver/i2c_master.h a 400 kHz: i registri dell’APDS-9960 sono a 8 bit, quindi una scrittura è un i2c_master_transmit() di due byte (registro e valore) e una lettura è un i2c_master_transmit_receive(). La FIFO si legge con una lettura multipla a partire dal registro 0xFC: il chip restituisce i dataset uno dopo l’altro, quattro byte ciascuno.
Il pin INT è gestito con driver/gpio.h: interrupt sul fronte di discesa, una ISR minimale che rilascia un semaforo e un task che fa tutto il lavoro I2C. Non si fanno mai transazioni I2C dentro una ISR: il driver usa mutex e code FreeRTOS che non sono utilizzabili in quel contesto.
Il codice
In main/CMakeLists.txt dichiara PRIV_REQUIRES esp_driver_i2c esp_driver_gpio. I valori di configurazione dei registri vengono dal datasheet Broadcom e corrispondono ai default della libreria SparkFun. Il main.c completo:
/*
* Sensore di gesture APDS-9960 con ESP32-C6 (ESP-IDF 5.4)
* - Nuovo driver i2c_master, prossimita' + motore gesture
* - Pin INT su interrupt GPIO, lettura della FIFO e decodifica su/giu/sinistra/destra
* Valori di configurazione: datasheet Broadcom APDS-9960 e default della
* libreria SparkFun APDS-9960.
* www.webappportal.com
*/
#include <stdio.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/i2c_master.h"
#include "driver/gpio.h"
#include "esp_log.h"
static const char *TAG = "gesture";
/* ---------- Pin ---------- */
#define I2C_SDA_GPIO GPIO_NUM_6
#define I2C_SCL_GPIO GPIO_NUM_7
#define APDS_INT_GPIO GPIO_NUM_5 /* INT open-drain, attivo basso */
/* ---------- Registri APDS-9960 ---------- */
#define APDS_ADDR 0x39
#define REG_ENABLE 0x80
#define REG_WTIME 0x83
#define REG_PPULSE 0x8E
#define REG_CONTROL 0x8F
#define REG_CONFIG2 0x90
#define REG_ID 0x92
#define REG_PDATA 0x9C
#define REG_GPENTH 0xA0
#define REG_GEXTH 0xA1
#define REG_GCONF1 0xA2
#define REG_GCONF2 0xA3
#define REG_GPULSE 0xA6
#define REG_GCONF3 0xAA
#define REG_GCONF4 0xAB
#define REG_GFLVL 0xAE
#define REG_GFIFO_U 0xFC
/* Bit di ENABLE */
#define EN_PON 0x01
#define EN_PEN 0x04
#define EN_WEN 0x08
#define EN_GEN 0x40
#define GESTURE_MIN_COUNTS 10 /* scarta dataset troppo deboli */
#define GESTURE_SENSITIVITY 50 /* soglia sul delta dei rapporti (%) */
typedef enum { DIR_NONE, DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } gesture_dir_t;
static i2c_master_dev_handle_t apds;
static SemaphoreHandle_t int_sem;
static esp_err_t apds_write(uint8_t reg, uint8_t val)
{
uint8_t buf[2] = { reg, val };
return i2c_master_transmit(apds, buf, 2, 100);
}
static esp_err_t apds_read(uint8_t reg, uint8_t *data, size_t len)
{
return i2c_master_transmit_receive(apds, ®, 1, data, len, 100);
}
static void IRAM_ATTR apds_isr(void *arg)
{
BaseType_t hp_woken = pdFALSE;
xSemaphoreGiveFromISR(int_sem, &hp_woken);
if (hp_woken) {
portYIELD_FROM_ISR();
}
}
static esp_err_t apds_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 = APDS_ADDR,
.scl_speed_hz = 400000,
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &dev_cfg, &apds));
uint8_t id = 0;
esp_err_t err = apds_read(REG_ID, &id, 1);
if (err != ESP_OK) {
return err;
}
ESP_LOGI(TAG, "APDS-9960 ID = 0x%02X", id);
if (id != 0xAB && id != 0x9C) {
ESP_LOGW(TAG, "ID inatteso: clone o altro chip? Proseguo comunque");
}
/* Configurazione a sensore spento, poi accensione */
apds_write(REG_ENABLE, 0x00);
apds_write(REG_WTIME, 0xFF); /* tempo di attesa minimo (2,78 ms) */
apds_write(REG_PPULSE, 0x89); /* prossimita': 16 us, 10 impulsi */
apds_write(REG_CONTROL, 0x09); /* LED 100 mA, PGAIN 4x, AGAIN 4x */
apds_write(REG_CONFIG2, 0x31); /* LED boost 300 % */
apds_write(REG_GPENTH, 40); /* soglia di ingresso nel motore gesture */
apds_write(REG_GEXTH, 30); /* soglia di uscita */
apds_write(REG_GCONF1, 0x40); /* interrupt dopo 4 dataset in FIFO */
apds_write(REG_GCONF2, 0x41); /* GGAIN 4x, LED 100 mA, attesa 2,8 ms */
apds_write(REG_GPULSE, 0xC9); /* gesture: 32 us, 10 impulsi */
apds_write(REG_GCONF3, 0x00); /* tutte e quattro le coppie di fotodiodi */
apds_write(REG_GCONF4, 0x02); /* GIEN: interrupt gesture abilitato */
return apds_write(REG_ENABLE, EN_PON | EN_WEN | EN_PEN | EN_GEN);
}
/* Decodifica: confronta i rapporti (U-D) e (L-R) del primo e dell'ultimo
* dataset "forte" del gesto, come nella libreria SparkFun */
static gesture_dir_t decode(const uint8_t (*d)[4], int n)
{
int first = -1, last = -1;
for (int i = 0; i < n; i++) {
if (d[i][0] > GESTURE_MIN_COUNTS && d[i][1] > GESTURE_MIN_COUNTS &&
d[i][2] > GESTURE_MIN_COUNTS && d[i][3] > GESTURE_MIN_COUNTS) {
if (first < 0) {
first = i;
}
last = i;
}
}
if (first < 0 || first == last) {
return DIR_NONE;
}
int ud_first = (d[first][0] - d[first][1]) * 100 / (d[first][0] + d[first][1]);
int lr_first = (d[first][2] - d[first][3]) * 100 / (d[first][2] + d[first][3]);
int ud_last = (d[last][0] - d[last][1]) * 100 / (d[last][0] + d[last][1]);
int lr_last = (d[last][2] - d[last][3]) * 100 / (d[last][2] + d[last][3]);
int ud_delta = ud_last - ud_first;
int lr_delta = lr_last - lr_first;
if (abs(ud_delta) < GESTURE_SENSITIVITY && abs(lr_delta) < GESTURE_SENSITIVITY) {
return DIR_NONE;
}
if (abs(ud_delta) > abs(lr_delta)) {
return (ud_delta > 0) ? DIR_DOWN : DIR_UP;
}
return (lr_delta > 0) ? DIR_RIGHT : DIR_LEFT;
}
static void gesture_task(void *arg)
{
static uint8_t data[128][4]; /* U, D, L, R per ogni dataset */
static const char *names[] = { "nessuno", "SU", "GIU'", "SINISTRA", "DESTRA" };
while (1) {
/* Se l'interrupt e' andato perso ma INT e' ancora basso, procedi lo stesso */
if (xSemaphoreTake(int_sem, pdMS_TO_TICKS(1000)) != pdTRUE &&
gpio_get_level(APDS_INT_GPIO) != 0) {
uint8_t prox = 0;
apds_read(REG_PDATA, &prox, 1);
ESP_LOGI(TAG, "Prossimita': %u", prox);
continue;
}
/* Scarica la FIFO finche' il motore gesture e' attivo (GMODE)
* o finche' restano dati; limite di sicurezza ~3 s */
int n = 0;
for (int iter = 0; iter < 100; iter++) {
uint8_t gconf4 = 0, level = 0;
apds_read(REG_GCONF4, &gconf4, 1);
apds_read(REG_GFLVL, &level, 1);
if (!(gconf4 & 0x01) && level == 0) {
break; /* gesto concluso */
}
if (level > 0) {
uint8_t buf[32 * 4];
if (level > 32) {
level = 32;
}
if (apds_read(REG_GFIFO_U, buf, level * 4) == ESP_OK) {
for (int i = 0; i < level && n < 128; i++, n++) {
data[n][0] = buf[i * 4 + 0];
data[n][1] = buf[i * 4 + 1];
data[n][2] = buf[i * 4 + 2];
data[n][3] = buf[i * 4 + 3];
}
}
}
vTaskDelay(pdMS_TO_TICKS(30));
}
/* GFIFO_CLR: svuota la FIFO e rilascia INT, mantenendo GIEN */
apds_write(REG_GCONF4, 0x06);
if (n > 0) {
gesture_dir_t dir = decode((const uint8_t (*)[4])data, n);
ESP_LOGI(TAG, "Gesto: %s (%d dataset)", names[dir], n);
}
}
}
void app_main(void)
{
int_sem = xSemaphoreCreateBinary();
gpio_config_t io = {
.pin_bit_mask = 1ULL << APDS_INT_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, /* INT e' open-drain */
.intr_type = GPIO_INTR_NEGEDGE,
};
ESP_ERROR_CHECK(gpio_config(&io));
ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_isr_handler_add(APDS_INT_GPIO, apds_isr, NULL));
esp_err_t err = apds_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "APDS-9960 non trovato: %s", esp_err_to_name(err));
return;
}
ESP_LOGI(TAG, "Pronto: passa la mano sopra il sensore a 5-10 cm");
xTaskCreate(gesture_task, "gesture", 4096, NULL, 5, NULL);
}
I punti chiave:
apds_init()legge il registro ID (0xAB per l’APDS-9960 secondo il datasheet; alcuni lotti rispondono 0x9C) e configura il chip a sensore spento.GCONF1 = 0x40genera l’interrupt quando in FIFO ci sono 4 dataset,GCONF4 = 0x02abilita l’interrupt del motore gesture, e infineENABLEaccende oscillatore (PON), attesa (WEN), prossimità (PEN) e gesture (GEN).CONFIG2 = 0x31porta il boost della corrente del LED al 300 %: aumenta la portata dei gesti a costo di più consumo durante gli impulsi.gesture_task()resta in attesa del semaforo. Se per un secondo non arriva nessun interrupt (e il pin INT non è rimasto basso), legge e stampa la prossimità (registro 0x9C), utile per capire se il LED funziona. Quando arriva l’interrupt, a intervalli di 30 ms leggeGFLVL(quanti dataset ci sono in FIFO) e il bit GMODE diGCONF4, che resta a 1 finché il motore gesture è attivo, e scarica la FIFO. Quando GMODE torna a 0 e la FIFO è vuota il gesto è concluso; il limite di 100 iterazioni evita di restare bloccati se la mano resta ferma sul sensore. Alla fine scriveGCONF4 = 0x06: il bit GFIFO_CLR svuota la FIFO e rilascia il pin INT, così il fronte di discesa successivo non va perso.decode()scarta i dataset con valori troppo bassi, prende il primo e l’ultimo, calcola la variazione dei rapporti ud e lr e la confronta conGESTURE_SENSITIVITY. Se entrambe le variazioni sono piccole il gesto è classificato “nessuno”.- L’array dei dati è
staticper non occupare 512 byte di stack del task.
Prova e risultati attesi
Passa la mano a 5-10 cm sopra il sensore, con un movimento deciso e di velocità media, in ciascuna delle quattro direzioni:
I (318) gesture: APDS-9960 ID = 0xAB I (322) gesture: Pronto: passa la mano sopra il sensore a 5-10 cm I (1322) gesture: Prossimita': 0 I (2322) gesture: Prossimita': 3 I (2981) gesture: Gesto: SINISTRA (14 dataset) I (4410) gesture: Gesto: DESTRA (12 dataset) I (6023) gesture: Gesto: SU (17 dataset) I (7190) gesture: Gesto: GIU' (15 dataset) I (9570) gesture: Gesto: nessuno (41 dataset)
L’ultima riga è quello che succede se tieni la mano ferma sopra il sensore: il motore gesture resta attivo e raccoglie molti dataset, ma senza una variazione netta tra inizio e fine.
Problemi comuni
- L’I2C funziona ma prossimità sempre 0 e nessun gesto: il LED IR non è alimentato (pin VL o ponticello sul breakout). Con una fotocamera dello smartphone senza filtro IR puoi a volte vedere il LED lampeggiare.
- ID diverso da 0xAB/0x9C: molti moduli economici montano cloni che rispondono con altri valori. Il codice prosegue comunque, ma il comportamento del motore gesture può differire.
- Direzioni invertite o ruotate: dipende dall’orientamento del breakout. Scambia i casi in
decode(). - Gesti riconosciuti male sotto luce forte: il sole o una lampada alogena saturano i fotodiodi. Scherma lateralmente il sensore o abbassa il guadagno in
GCONF2. - Nessun interrupt: INT senza pull-up, oppure
GCONF4non ha il bit GIEN (bit 1). Verifica anche che l’interrupt sia configurato sul fronte di discesa. - Il sensore dietro un vetro non funziona: il riflesso del vetro stesso satura la prossimità. Serve una finestra IR adatta e le funzioni di offset (
GOFFSET_*) del datasheet.
Va oltre
- Usa i gesti per navigare le pagine di un display, come la dashboard della stazione meteo, oppure per inserire e disinserire un’area dell’antifurto in combinazione con un codice.
- Aggiungi i canali di luce ambiente e colore (registri CDATA, RDATA, GDATA, BDATA) per regolare automaticamente la luminosità di un display.
- Pubblica i gesti come eventi MQTT per Home Assistant, come negli altri progetti di domotica.
- Altri tutorial sulla famiglia ESP32: sezione ESP32.
