Sensore di livello liquido con ESP32-C6: galleggianti e pressione

Sensore di livello liquido con ESP32-C6: galleggianti e pressione

Sapere quanta acqua c’è in una cisterna, in un serbatoio di recupero piovano o in un pozzetto con pompa di drenaggio è un problema classico di domotica. Non esiste però “il” sensore di livello: ci sono tecnologie diverse, con costi, precisione e affidabilità molto diversi. In questo tutorial le confronti e poi ne implementi due sull’ESP32-C6 con ESP-IDF 5.4: gli interruttori a galleggiante letti con interrupt e debounce, e un trasduttore di pressione idrostatico 0,5-4,5 V letto con l’ADC tramite partitore, per ottenere il livello in centimetri.

Cosa serve

Componente Esempio Note
Scheda ESP32-C6 ESP32-C6-DevKitC-1 Adatta i pin se usi un’altra board
Interruttori a galleggiante 2 × galleggiante a contatto reed, verticale o a cavo Contatto pulito (senza tensione), NA o NC
Trasduttore di pressione Sensore idrostatico con uscita 0,5-4,5 V e alimentazione 5 V Scegli il fondo scala in base all’altezza del serbatoio; deve essere di tipo relativo (gauge)
Partitore 10 kΩ + 15 kΩ, 1 % Riporta 0,5-4,5 V a 0,3-2,7 V
Condensatore 100 nF ceramico Sul pin ADC verso GND, riduce il rumore
Alimentazione 5 V Pin 5V della DevKit (da USB) o alimentatore Per il trasduttore

Come funziona

Prima di scrivere codice conviene scegliere la tecnologia giusta:

Metodo Esempio Cosa misura Pro Contro
Galleggiante Reed magnetico Soglie discrete (pieno/vuoto) Economico, robusto, nessuna taratura Solo on/off, parti mobili che si possono incrostare
Capacitivo senza contatto XKC-Y25 Presenza di liquido dietro la parete Si incolla all’esterno di un contenitore non metallico, nessun foro Solo soglia, sensibile allo spessore e al materiale della parete
Ultrasonico impermeabile JSN-SR04T Distanza dalla superficie Continuo, senza contatto con il liquido Zona cieca di circa 20-25 cm, disturbato da schiuma, condensa e pareti strette
Pressione idrostatica Trasduttore 0,5-4,5 V o 4-20 mA Colonna di liquido sopra il sensore Continuo, preciso, indipendente dalla forma del serbatoio Più costoso, va immerso o montato sul fondo, dipende dalla densità del liquido

Il galleggiante è un magnete dentro un corpo che galleggia: quando il liquido lo solleva, il magnete chiude (o apre) un contatto reed. Per l’elettronica è un pulsante, con gli stessi rimbalzi meccanici, a cui si aggiunge l’onda sulla superficie che può farlo oscillare.

Il trasduttore idrostatico sfrutta la legge di Stevino: la pressione sul fondo è p = ρ · g · h. Con acqua dolce (ρ ≈ 1000 kg/m³) ogni kPa corrisponde a circa 10,2 cm di colonna. Il sensore deve essere di tipo relativo (gauge, con capillare di sfiato nel cavo nei modelli sommergibili), altrimenti misureresti anche la pressione atmosferica e le sue variazioni meteo. L’uscita 0,5-4,5 V è lineare: 0,5 V a pressione zero, 4,5 V a fondo scala. Il fatto che lo zero non sia a 0 V è voluto: una lettura vicina a 0 V indica un guasto (cavo tagliato, sensore spento), non un serbatoio vuoto.

Collegamenti

Pin sensore GPIO ESP32-C6 Note
Galleggiante minimo, filo 1 GPIO10 Pull-up interno attivo
Galleggiante massimo, filo 1 GPIO11 Pull-up interno attivo
Galleggianti, filo 2 GND Contatto chiuso = livello 0
Trasduttore +V (rosso) 5V Verifica i colori sul datasheet del tuo modello
Trasduttore GND (nero) GND Massa comune con l’ESP32-C6
Trasduttore OUT GPIO3 (ADC1_CH3) tramite partitore 10 kΩ in alto, 15 kΩ verso GND, 100 nF sul pin ADC

Progettare il partitore per l’ADC a 3,3 V

I GPIO dell’ESP32-C6 non tollerano 5 V e l’ADC con attenuazione 12 dB arriva a poco più di 3 V utili. Il segnale del trasduttore va da 0,5 a 4,5 V, ma in caso di guasto può salire fino alla tensione di alimentazione, quindi dimensiona il partitore sui 5 V, non sui 4,5 V:

Vadc = Vout · R2 / (R1 + R2)
R1 = 10 kΩ, R2 = 15 kΩ  →  rapporto 0,6
0,5 V → 0,30 V    4,5 V → 2,70 V    5,0 V → 3,00 V

Così resti sotto i 3,3 V anche nel caso peggiore e lavori nella parte più lineare dell’ADC. Con rapporti più “aggressivi” (per esempio 2/3) guadagni un po’ di risoluzione ma a 5 V arrivi a 3,33 V, già oltre il limite. Il carico totale di 25 kΩ è accettabile per la maggior parte dei trasduttori, ma controlla sul datasheet la resistenza di carico minima ammessa. Usa resistenze all’1 %: l’errore del rapporto si trasferisce direttamente sul livello.

Se il tuo trasduttore è a 4-20 mA, il principio è lo stesso ma al posto del partitore metti una resistenza di shunt verso GND: con 150 Ω ottieni 0,6-3,0 V, già nel range dell’ADC. Il loop in corrente è più robusto su cavi lunghi, ma richiede un’alimentazione di loop più alta (tipicamente 12-24 V).

La periferica dell’ESP32-C6

Per i galleggianti usi driver/gpio.h con interrupt su entrambi i fronti (GPIO_INTR_ANYEDGE). La ISR fa il minimo indispensabile: mette in coda il numero del GPIO. Il debounce vero lo fa un task: quando riceve un evento aspetta 50 ms, svuota la coda dagli eventi generati dai rimbalzi e legge lo stato stabile. È un approccio semplice che non richiede timer aggiuntivi.

Per il trasduttore usi esp_adc/adc_oneshot.h con la calibrazione curve fitting (esp_adc/adc_cali_scheme.h), che converte il valore grezzo in millivolt usando i dati di calibrazione di fabbrica. L’ADC1 dell’ESP32-C6 è sui GPIO0-6: GPIO3 è libero sulla DevKitC-1 e non è di strapping.

Il codice

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

/*
 * Sensore di livello liquido con ESP32-C6 (ESP-IDF 5.4)
 * - Due interruttori a galleggiante su GPIO con interrupt + debounce software
 * - Trasduttore di pressione idrostatico 0,5-4,5 V su partitore + ADC1 -> livello in cm
 * www.webappportal.com
 */
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.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 = "livello";

/* ---------- Galleggianti ---------- */
#define FLOAT_LOW_GPIO      GPIO_NUM_10     /* galleggiante di minimo */
#define FLOAT_HIGH_GPIO     GPIO_NUM_11     /* galleggiante di massimo */
#define FLOAT_ACTIVE_LEVEL  0               /* contatto chiuso verso GND = livello raggiunto */
#define DEBOUNCE_MS         50

/* ---------- Trasduttore di pressione ---------- */
#define PT_ADC_CHANNEL      ADC_CHANNEL_3   /* GPIO3 = ADC1_CH3 sull'ESP32-C6 */
#define PT_SAMPLES          32
#define DIV_R_TOP_OHM       10000.0f        /* dal sensore al pin ADC */
#define DIV_R_BOTTOM_OHM    15000.0f        /* dal pin ADC a GND */
#define PT_V_MIN            0.5f            /* uscita a pressione zero (datasheet) */
#define PT_V_MAX            4.5f            /* uscita a fondo scala (datasheet) */
#define PT_FULL_SCALE_KPA   10.0f           /* ESEMPIO: fondo scala del TUO sensore */
#define LIQUID_DENSITY      1000.0f         /* kg/m^3, acqua dolce */
#define GRAVITY             9.80665f        /* m/s^2 */

static QueueHandle_t float_evt_queue;
static adc_oneshot_unit_handle_t adc1;
static adc_cali_handle_t adc1_cali;
static bool adc_calibrated;

static void IRAM_ATTR float_isr(void *arg)
{
    uint32_t gpio = (uint32_t)arg;
    BaseType_t hp_woken = pdFALSE;
    xQueueSendFromISR(float_evt_queue, &gpio, &hp_woken);
    if (hp_woken) {
        portYIELD_FROM_ISR();
    }
}

static const char *float_state_str(int level)
{
    return (level == FLOAT_ACTIVE_LEVEL) ? "RAGGIUNTO" : "libero";
}

static void float_task(void *arg)
{
    int last_low = gpio_get_level(FLOAT_LOW_GPIO);
    int last_high = gpio_get_level(FLOAT_HIGH_GPIO);
    ESP_LOGI(TAG, "Galleggiante minimo: %s, massimo: %s",
             float_state_str(last_low), float_state_str(last_high));

    uint32_t gpio;
    while (1) {
        if (xQueueReceive(float_evt_queue, &gpio, portMAX_DELAY)) {
            /* Debounce: aspetta che il contatto si assesti, poi scarta gli
             * eventi accumulati nel frattempo e leggi lo stato stabile */
            vTaskDelay(pdMS_TO_TICKS(DEBOUNCE_MS));
            xQueueReset(float_evt_queue);

            int low = gpio_get_level(FLOAT_LOW_GPIO);
            int high = gpio_get_level(FLOAT_HIGH_GPIO);
            if (low != last_low) {
                ESP_LOGI(TAG, "Galleggiante MINIMO: %s", float_state_str(low));
                last_low = low;
            }
            if (high != last_high) {
                ESP_LOGW(TAG, "Galleggiante MASSIMO: %s", float_state_str(high));
                last_high = high;
            }
        }
    }
}

static void floats_init(void)
{
    gpio_config_t io = {
        .pin_bit_mask = (1ULL << FLOAT_LOW_GPIO) | (1ULL << FLOAT_HIGH_GPIO),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,   /* pull-up interno (~45 kohm): meglio 10k esterni su cavi lunghi */
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_ANYEDGE,
    };
    ESP_ERROR_CHECK(gpio_config(&io));

    float_evt_queue = xQueueCreate(16, sizeof(uint32_t));
    ESP_ERROR_CHECK(gpio_install_isr_service(0));
    ESP_ERROR_CHECK(gpio_isr_handler_add(FLOAT_LOW_GPIO, float_isr, (void *)FLOAT_LOW_GPIO));
    ESP_ERROR_CHECK(gpio_isr_handler_add(FLOAT_HIGH_GPIO, float_isr, (void *)FLOAT_HIGH_GPIO));
    xTaskCreate(float_task, "float_task", 3072, NULL, 5, NULL);
}

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,
        .bitwidth = ADC_BITWIDTH_DEFAULT,
    };
    ESP_ERROR_CHECK(adc_oneshot_config_channel(adc1, PT_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 = PT_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: misure meno accurate");
    }
}

/* Tensione in uscita dal trasduttore (prima del partitore), in volt */
static float pt_read_volts(void)
{
    int sum = 0;
    for (int i = 0; i < PT_SAMPLES; i++) {
        int raw = 0;
        ESP_ERROR_CHECK(adc_oneshot_read(adc1, PT_ADC_CHANNEL, &raw));
        sum += raw;
    }
    int raw_avg = sum / PT_SAMPLES;
    int mv = 0;
    if (adc_calibrated) {
        ESP_ERROR_CHECK(adc_cali_raw_to_voltage(adc1_cali, raw_avg, &mv));
    } else {
        mv = raw_avg * 3300 / 4095;         /* stima grossolana senza calibrazione */
    }
    float v_adc = mv / 1000.0f;
    return v_adc * (DIV_R_TOP_OHM + DIV_R_BOTTOM_OHM) / DIV_R_BOTTOM_OHM;
}

void app_main(void)
{
    floats_init();
    adc_init();

    while (1) {
        float v = pt_read_volts();

        if (v < 0.3f || v > 4.7f) {
            /* Fuori dalla finestra 0,5-4,5 V con margine: cavo interrotto,
             * sensore non alimentato o guasto */
            ESP_LOGE(TAG, "Trasduttore: %.3f V fuori range, controlla cablaggio", v);
        } else {
            float kpa = (v - PT_V_MIN) / (PT_V_MAX - PT_V_MIN) * PT_FULL_SCALE_KPA;
            if (kpa < 0.0f) {
                kpa = 0.0f;
            }
            /* Legge di Stevino: p = rho * g * h  ->  h = p / (rho * g) */
            float level_cm = (kpa * 1000.0f) / (LIQUID_DENSITY * GRAVITY) * 100.0f;
            ESP_LOGI(TAG, "Trasduttore: %.3f V  pressione=%.2f kPa  livello=%.1f cm",
                     v, kpa, level_cm);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

I punti chiave:

  • float_isr() è marcata IRAM_ATTR e usa solo xQueueSendFromISR(): niente log e niente attese dentro un interrupt.
  • float_task() implementa il debounce: dopo il primo fronte attende DEBOUNCE_MS, chiama xQueueReset() per buttare i fronti spuri e confronta lo stato con l’ultimo stabile. Se il liquido è agitato, alza il tempo a qualche centinaio di millisecondi o aggiungi un conteggio di letture consecutive uguali.
  • FLOAT_ACTIVE_LEVEL: se i tuoi galleggianti sono normalmente chiusi, o li monti capovolti, basta cambiare questa costante.
  • pt_read_volts() media 32 campioni, converte in mV con la calibrazione e riporta la tensione “a monte” del partitore moltiplicando per (R1 + R2) / R2.
  • Controllo di plausibilità: sotto 0,3 V o sopra 4,7 V il codice segnala un guasto invece di mostrare un livello. È il vantaggio dell’uscita con offset a 0,5 V.
  • PT_FULL_SCALE_KPA è un esempio (10 kPa, circa 1 m d’acqua): sostituiscilo con il fondo scala del tuo sensore. Se il liquido non è acqua dolce, cambia anche LIQUID_DENSITY.

Prova e risultati attesi

Con un trasduttore da 10 kPa appoggiato sul fondo di un secchio con circa 30 cm d’acqua e il galleggiante di minimo sollevato:

I (318) livello: Galleggiante minimo: RAGGIUNTO, massimo: libero
I (1328) livello: Trasduttore: 1.702 V  pressione=3.01 kPa  livello=30.6 cm
I (2338) livello: Trasduttore: 1.698 V  pressione=2.99 kPa  livello=30.4 cm
W (5120) livello: Galleggiante MASSIMO: RAGGIUNTO
I (5338) livello: Trasduttore: 3.451 V  pressione=7.38 kPa  livello=75.2 cm

Verifica la catena di misura con un metro: se il livello letto ha un offset costante, correggi lo zero misurando la tensione con sensore fuori dall’acqua; se l’errore cresce con il livello, controlla i valori reali delle resistenze del partitore e il fondo scala.

Problemi comuni

  • Il galleggiante genera decine di eventi: rimbalzi del reed o onde sulla superficie. Aumenta DEBOUNCE_MS o monta il galleggiante in un tubo di calma (un pezzo di tubo forato che smorza le onde).
  • Letture casuali con cavi lunghi: il pull-up interno è debole. Aggiungi un pull-up esterno da 10 kΩ e un condensatore da 100 nF verso GND vicino all’ESP32-C6.
  • Il livello deriva con il meteo: hai un sensore di pressione assoluta, oppure il capillare di sfiato è ostruito o piegato.
  • Livello che oscilla di qualche mm con il sensore fermo: il 5 V da USB è rumoroso e l’uscita del trasduttore è raziometrica rispetto all’alimentazione. Usa un 5 V stabile o misura anche l’alimentazione con un secondo canale ADC e correggi.
  • Tensione sempre 0 V: GND del trasduttore non in comune con l’ESP32-C6, oppure hai invertito le resistenze del partitore.

Va oltre

  • Converti il livello in litri con la geometria del serbatoio (sezione costante, cilindro orizzontale, ecc.).
  • Usa il galleggiante di massimo come sicurezza hardware indipendente per fermare una pompa, anche se il sensore analogico si guasta.
  • Pubblica livello e allarmi su MQTT verso Home Assistant, come negli altri progetti della sezione domotica; un sensore di allagamento a galleggiante si integra bene anche nell’antifurto come ingresso tecnico.
  • Per un serbatoio all’aperto, abbina i dati alla pioggia misurata dalla stazione meteo.
  • Altri tutorial sulla famiglia ESP32: sezione ESP32.

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