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()è marcataIRAM_ATTRe usa soloxQueueSendFromISR(): niente log e niente attese dentro un interrupt.float_task()implementa il debounce: dopo il primo fronte attendeDEBOUNCE_MS, chiamaxQueueReset()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 ancheLIQUID_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_MSo 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.
