Sensore di umidità con ESP32-C6: SHT40 e umidità del terreno

Sensore di umidità con ESP32-C6: SHT40 e umidità del terreno

Misurare l’umidità è una delle prime cose che si chiedono a un nodo sensore: in una serra, in cantina, in un armadio rack o in una stazione meteo. In questo tutorial colleghi all’ESP32-C6 due sensori molto diversi tra loro: il Sensirion SHT40, digitale e preciso, per temperatura e umidità relativa dell’aria, e un sensore capacitivo di umidità del terreno, analogico ed economico, letto con l’ADC. Usi il nuovo driver I2C master e il driver ADC oneshot con calibrazione di ESP-IDF 5.4, e calcoli anche il punto di rugiada.

Cosa serve

Componente Esempio Note
Scheda ESP32-C6 ESP32-C6-DevKitC-1 Qualsiasi board C6 va bene, adatta i pin
Sensore T/RH digitale Breakout SHT40 (Adafruit, Seeed o generico) Alimentazione 3,3 V, interfaccia I2C, indirizzo 0x44 per la versione SHT40-AD1B
Sensore umidità terreno Modulo “Capacitive Soil Moisture Sensor v1.2” o simile Uscita analogica; alimentalo a 3,3 V
Resistenze di pull-up 2 × 4,7 kΩ Solo se il breakout non le ha già
Varie Breadboard, cavetti, bicchiere d’acqua L’acqua serve per la calibrazione del sensore terreno

Come funziona

Umidità relativa e punto di rugiada

L’aria può contenere una quantità massima di vapore acqueo che cresce con la temperatura. L’umidità relativa (RH) è il rapporto, in percentuale, tra la pressione parziale del vapore presente e quella di saturazione alla stessa temperatura. Per questo RH da sola dice poco: 60 % a 10 °C e 60 % a 30 °C sono quantità d’acqua molto diverse.

Il punto di rugiada è la temperatura alla quale, raffreddando l’aria senza cambiarne il contenuto di vapore, si arriva alla saturazione e compare la condensa. È il numero che conta davvero per capire se su una parete fredda o dentro un contenitore si formerà acqua. Lo calcoli con la formula di Magnus, usando le costanti che Sensirion indica per l’acqua liquida (β = 17,62, λ = 243,12 °C):

γ = ln(RH/100) + β·T / (λ + T)
Td = λ·γ / (β − γ)

È un’approssimazione, ma nel campo di temperature tipico di casa e giardino l’errore è ben inferiore all’incertezza del sensore.

SHT40

Dentro l’SHT40 c’è un sensore capacitivo di umidità (un dielettrico polimerico che assorbe vapore e cambia capacità) e un sensore di temperatura, con ADC e compensazione già integrati. Tu invii un comando da un byte, aspetti la conversione e leggi sei byte: due per la temperatura, due per l’umidità, ognuno seguito da un CRC-8. Le formule di conversione del datasheet sono:

T [°C] = −45 + 175 · S_T / 65535
RH [%] = −6 + 125 · S_RH / 65535

Il valore di RH può uscire leggermente da 0-100 %: il datasheet stesso consiglia di limitarlo.

Sensore capacitivo del terreno

Il modulo capacitivo ha due “armature” ricavate sul PCB e coperte dal solder mask: non c’è metallo esposto e quindi niente corrosione, a differenza dei vecchi sensori resistivi a forchetta. Un oscillatore (spesso un 555) pilota il condensatore formato dalle piste e dal terreno; più il terreno è bagnato, più sale la costante dielettrica e la capacità, e più scende la tensione in uscita. Il risultato non è una misura assoluta di contenuto d’acqua: devi calibrare il sensore agli estremi (aria e acqua) e ragionare in percentuale relativa.

Collegamenti

Pin sensore GPIO ESP32-C6 Note
SHT40 VIN / VCC 3V3 Molti breakout accettano anche 5 V, ma a 3,3 V i livelli I2C sono già corretti
SHT40 GND GND
SHT40 SDA GPIO6 Pull-up 4,7 kΩ verso 3,3 V (spesso già sul breakout)
SHT40 SCL GPIO7 Pull-up 4,7 kΩ verso 3,3 V
Sensore terreno VCC 3V3 Non usare 5 V: l’uscita potrebbe superare il fondo scala dell’ADC
Sensore terreno GND GND
Sensore terreno AOUT GPIO2 (ADC1_CH2) Sull’ESP32-C6 l’ADC1 è su GPIO0-6

Il driver abilita anche i pull-up interni dei GPIO, ma sono deboli (decine di kΩ): vanno bene per un test a 100 kHz su cavetti corti, non per un bus lungo. Se il tuo breakout ha già i pull-up non aggiungerne altri in parallelo senza motivo. Il pin GPIO2 è libero sulla DevKitC-1 e non è un pin di strapping, a differenza di GPIO8, 9 e 15.

La periferica dell’ESP32-C6

Per l’SHT40 usi il driver driver/i2c_master.h, introdotto in ESP-IDF 5.2 e ormai quello di riferimento: crei un bus con i2c_new_master_bus(), ci agganci un device con il suo indirizzo e la sua velocità, e poi chiami i2c_master_transmit() e i2c_master_receive(). Niente più command link da costruire a mano come nel vecchio driver/i2c.h, che in ESP-IDF 5.4 è deprecato. La funzione i2c_master_probe() ti dice subito se all’indirizzo 0x44 risponde qualcuno.

Per il sensore del terreno usi esp_adc/adc_oneshot.h: una conversione a richiesta, perfetta per un segnale che cambia in minuti. L’attenuazione ADC_ATTEN_DB_12 porta il fondo scala a circa 3,3 V. Il valore grezzo dell’ADC non è lineare né identico tra chip diversi, per cui crei uno schema di calibrazione con adc_cali_create_scheme_curve_fitting() (sull’ESP32-C6 è supportato il curve fitting, che usa i coefficienti scritti in eFuse in fabbrica) e converti in millivolt con adc_cali_raw_to_voltage().

Il codice

Crea un progetto ESP-IDF con main/main.c e in main/CMakeLists.txt dichiara le dipendenze PRIV_REQUIRES esp_driver_i2c esp_adc. Questo è il main.c completo:

/*
 * Sensore di umidita' con ESP32-C6 (ESP-IDF 5.4)
 * - SHT40 su I2C (nuovo driver i2c_master): temperatura, umidita' relativa, punto di rugiada
 * - Sensore capacitivo di umidita' del terreno su ADC1 (adc_oneshot + calibrazione)
 * www.webappportal.com
 */
#include <stdio.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c_master.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_log.h"

static const char *TAG = "umidita";

/* ---------- Pin e parametri ---------- */
#define I2C_SDA_GPIO        GPIO_NUM_6
#define I2C_SCL_GPIO        GPIO_NUM_7
#define SHT40_ADDR          0x44        /* SHT40-AD1B: 0x44 (altre varianti: vedi datasheet) */
#define SHT40_CMD_MEAS_HIGH 0xFD        /* misura T+RH, alta ripetibilita', senza heater */
#define SHT40_CMD_SOFTRESET 0x94

#define SOIL_ADC_CHANNEL    ADC_CHANNEL_2   /* GPIO2 = ADC1_CH2 sull'ESP32-C6 */
#define SOIL_SAMPLES        16
/* Valori da misurare sul TUO sensore (vedi articolo): mV in aria e in acqua */
#define SOIL_MV_DRY         2600
#define SOIL_MV_WET         1150

static i2c_master_dev_handle_t sht40;
static adc_oneshot_unit_handle_t adc1;
static adc_cali_handle_t adc1_cali;
static bool adc_calibrated;

/* CRC-8 del datasheet Sensirion: polinomio 0x31, init 0xFF */
static uint8_t sht_crc8(const uint8_t *data, int len)
{
    uint8_t crc = 0xFF;
    for (int i = 0; i < len; i++) {
        crc ^= data[i];
        for (int b = 0; b < 8; b++) {
            crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1);
        }
    }
    return crc;
}

static esp_err_t sht40_read(float *temp_c, float *rh)
{
    uint8_t cmd = SHT40_CMD_MEAS_HIGH;
    uint8_t rx[6];

    esp_err_t err = i2c_master_transmit(sht40, &cmd, 1, 100);
    if (err != ESP_OK) {
        return err;
    }
    /* Alta ripetibilita': max ~8,3 ms secondo il datasheet, aspettiamo di piu' */
    vTaskDelay(pdMS_TO_TICKS(20));
    err = i2c_master_receive(sht40, rx, sizeof(rx), 100);
    if (err != ESP_OK) {
        return err;
    }
    if (sht_crc8(&rx[0], 2) != rx[2] || sht_crc8(&rx[3], 2) != rx[5]) {
        return ESP_ERR_INVALID_CRC;
    }
    uint16_t t_raw = (uint16_t)((rx[0] << 8) | rx[1]);
    uint16_t h_raw = (uint16_t)((rx[3] << 8) | rx[4]);

    *temp_c = -45.0f + 175.0f * (float)t_raw / 65535.0f;
    float h = -6.0f + 125.0f * (float)h_raw / 65535.0f;
    /* Il datasheet consiglia di limitare RH all'intervallo 0..100 % */
    if (h < 0.0f) {
        h = 0.0f;
    }
    if (h > 100.0f) {
        h = 100.0f;
    }
    *rh = h;
    return ESP_OK;
}

/* Punto di rugiada con la formula di Magnus (costanti Sensirion per acqua liquida) */
static float dew_point_c(float t, float rh)
{
    const float beta = 17.62f;
    const float lambda = 243.12f;
    if (rh < 0.1f) {
        rh = 0.1f;   /* evita log(0) */
    }
    float gamma = logf(rh / 100.0f) + beta * t / (lambda + t);
    return lambda * gamma / (beta - gamma);
}

static void i2c_init(void)
{
    i2c_master_bus_config_t bus_cfg = {
        .i2c_port = -1,                     /* scelta automatica della porta */
        .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, /* utile solo come riserva: usa pull-up esterni */
    };
    i2c_master_bus_handle_t bus;
    ESP_ERROR_CHECK(i2c_new_master_bus(&bus_cfg, &bus));

    ESP_ERROR_CHECK(i2c_master_probe(bus, SHT40_ADDR, 100));

    i2c_device_config_t dev_cfg = {
        .dev_addr_length = I2C_ADDR_BIT_LEN_7,
        .device_address = SHT40_ADDR,
        .scl_speed_hz = 100000,
    };
    ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &dev_cfg, &sht40));

    uint8_t cmd = SHT40_CMD_SOFTRESET;
    ESP_ERROR_CHECK(i2c_master_transmit(sht40, &cmd, 1, 100));
    vTaskDelay(pdMS_TO_TICKS(10));
}

static void adc_init(void)
{
    adc_oneshot_unit_init_cfg_t unit_cfg = {
        .unit_id = ADC_UNIT_1,
    };
    ESP_ERROR_CHECK(adc_oneshot_new_unit(&unit_cfg, &adc1));

    adc_oneshot_chan_cfg_t chan_cfg = {
        .atten = ADC_ATTEN_DB_12,           /* fondo scala di circa 3,3 V */
        .bitwidth = ADC_BITWIDTH_DEFAULT,
    };
    ESP_ERROR_CHECK(adc_oneshot_config_channel(adc1, SOIL_ADC_CHANNEL, &chan_cfg));

#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
    adc_cali_curve_fitting_config_t cali_cfg = {
        .unit_id = ADC_UNIT_1,
        .chan = SOIL_ADC_CHANNEL,
        .atten = ADC_ATTEN_DB_12,
        .bitwidth = ADC_BITWIDTH_DEFAULT,
    };
    adc_calibrated = (adc_cali_create_scheme_curve_fitting(&cali_cfg, &adc1_cali) == ESP_OK);
#endif
    if (!adc_calibrated) {
        ESP_LOGW(TAG, "Calibrazione ADC non disponibile: uso i valori grezzi");
    }
}

static int soil_read_mv(void)
{
    int sum = 0;
    for (int i = 0; i < SOIL_SAMPLES; i++) {
        int raw = 0;
        ESP_ERROR_CHECK(adc_oneshot_read(adc1, SOIL_ADC_CHANNEL, &raw));
        sum += raw;
        vTaskDelay(pdMS_TO_TICKS(2));
    }
    int raw_avg = sum / SOIL_SAMPLES;
    int mv = raw_avg;
    if (adc_calibrated) {
        ESP_ERROR_CHECK(adc_cali_raw_to_voltage(adc1_cali, raw_avg, &mv));
    }
    return mv;
}

/* Sensore capacitivo: tensione ALTA = terreno secco, BASSA = terreno bagnato */
static float soil_percent(int mv)
{
    float pct = 100.0f * (float)(SOIL_MV_DRY - mv) / (float)(SOIL_MV_DRY - SOIL_MV_WET);
    if (pct < 0.0f) {
        pct = 0.0f;
    }
    if (pct > 100.0f) {
        pct = 100.0f;
    }
    return pct;
}

void app_main(void)
{
    i2c_init();
    adc_init();
    ESP_LOGI(TAG, "SHT40 e sensore terreno pronti");

    while (1) {
        float t, rh;
        esp_err_t err = sht40_read(&t, &rh);
        if (err == ESP_OK) {
            ESP_LOGI(TAG, "Aria: T=%.2f C  RH=%.1f %%  Rugiada=%.1f C",
                     t, rh, dew_point_c(t, rh));
        } else {
            ESP_LOGE(TAG, "Errore lettura SHT40: %s", esp_err_to_name(err));
        }

        int mv = soil_read_mv();
        ESP_LOGI(TAG, "Terreno: %d mV  umidita' relativa=%.0f %%", mv, soil_percent(mv));

        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

I punti chiave:

  • sht40_read(): invia 0xFD (misura ad alta ripetibilità, heater spento), aspetta 20 ms (il datasheet indica un massimo di circa 8,3 ms, ma con il tick FreeRTOS di default a 10 ms conviene stare larghi) e legge 6 byte. Se un CRC non torna la funzione restituisce ESP_ERR_INVALID_CRC: su un bus disturbato è meglio scartare una lettura che loggare un valore sbagliato.
  • sht_crc8(): CRC-8 con polinomio 0x31 e valore iniziale 0xFF, come da datasheet.
  • dew_point_c(): la formula di Magnus vista sopra; il limite inferiore su RH evita il logaritmo di zero.
  • adc_init(): se la calibrazione non è disponibile (eFuse non programmati) il codice continua con i valori grezzi e lo segnala con un warning.
  • soil_read_mv(): media di 16 letture per ridurre il rumore del 555 e dell’ADC.
  • SOIL_MV_DRY e SOIL_MV_WET: i valori nel codice sono solo segnaposto. Misura il tuo sensore: annota i mV con il sensore asciutto in aria, poi con la parte sensibile immersa in acqua fino alla linea di riferimento stampata sul PCB (mai oltre: l’elettronica non è impermeabile), e sostituiscili. La funzione soil_percent() fa una semplice interpolazione lineare tra i due estremi.

Prova e risultati attesi

Compila e carica con idf.py -p COMx flash monitor. Con il sensore del terreno in aria dovresti vedere qualcosa del genere (i valori dipendono dall’ambiente e dal tuo esemplare):

I (312) umidita: SHT40 e sensore terreno pronti
I (342) umidita: Aria: T=23.41 C  RH=48.7 %  Rugiada=12.0 C
I (382) umidita: Terreno: 2594 mV  umidita' relativa=0 %
I (2392) umidita: Aria: T=23.43 C  RH=48.6 %  Rugiada=12.0 C
I (2432) umidita: Terreno: 1480 mV  umidita' relativa=77 %

Per verificare l’SHT40 basta alitarci sopra: l’umidità sale di decine di punti in un secondo e torna giù in poche decine di secondi. Per il sensore del terreno, infilalo in un vaso appena annaffiato e poi in uno asciutto.

Problemi comuni

  • Il programma si ferma subito con ESP_ERR_NOT_FOUND su i2c_master_probe: SDA e SCL invertiti, sensore non alimentato, oppure hai una variante SHT40 con indirizzo diverso da 0x44 (esistono versioni a 0x45 e 0x46: controlla la sigla completa sul datasheet).
  • Errori CRC saltuari: cavi lunghi o pull-up troppo deboli. Aggiungi pull-up esterni da 4,7 kΩ o scendi di frequenza.
  • Temperatura sempre qualche grado più alta del reale: il sensore è troppo vicino all’ESP32-C6 o al regolatore, che scaldano. Allontanalo con qualche centimetro di cavo.
  • RH bloccata al 100 % dopo una notte in ambiente saturo: gli SHT4x hanno un heater integrato proprio per recuperare dopo lunghe esposizioni a condensa; va usato con parsimonia e seguendo il datasheet (i comandi sono diversi da quello di misura normale).
  • Il sensore del terreno legge circa 3300 mV fissi: lo stai alimentando a 5 V oppure il pin AOUT è scollegato. Alcuni cloni economici hanno anche un regolatore mancante o un timer 555 sbagliato: se l’uscita non cambia tra aria e acqua, cambia modulo.
  • Letture che derivano nei giorni: i bordi del PCB tagliati non sono protetti e assorbono acqua. Una mano di smalto trasparente o di resina sui bordi e sull’elettronica ne allunga molto la vita.

Va oltre

  • Porta i valori di temperatura, umidità e punto di rugiada nella dashboard della stazione meteo, oppure inviali via MQTT a Home Assistant come nei progetti della sezione domotica.
  • Per un nodo a batteria, alimenta il sensore del terreno da un GPIO o da un MOSFET solo durante la misura e metti l’ESP32-C6 in deep sleep tra una lettura e l’altra.
  • Salva i valori di calibrazione del terreno in NVS invece di tenerli nel codice, con un comando seriale per acquisirli sul campo.
  • Altri tutorial sulla famiglia ESP32 li trovi nella sezione ESP32.

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