Sensore di gesture APDS-9960 con ESP32-C6 ed ESP-IDF 5.4

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, &reg, 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 = 0x40 genera l’interrupt quando in FIFO ci sono 4 dataset, GCONF4 = 0x02 abilita l’interrupt del motore gesture, e infine ENABLE accende oscillatore (PON), attesa (WEN), prossimità (PEN) e gesture (GEN).
  • CONFIG2 = 0x31 porta 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 legge GFLVL (quanti dataset ci sono in FIFO) e il bit GMODE di GCONF4, 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 scrive GCONF4 = 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 con GESTURE_SENSITIVITY. Se entrambe le variazioni sono piccole il gesto è classificato “nessuno”.
  • L’array dei dati è static per 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 GCONF4 non 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.

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