Blog Elettronica e maker

Il contatore manda un impulso ogni litro: contarli tutti con l'ESP32

La prima versione di un contaimpulsi si scrive in dieci righe e perde i colpi dopo una settimana. L'ESP32 ha otto contatori in hardware che non li perdono — a patto di sapere che cosa il loro filtro non fa.

Un contatore d'acqua con uscita a impulsi chiude un contatto ogni litro; uno di energia lo fa ogni wattora, o ogni dieci. È informazione gratuita: per raccoglierla bastano due fili, una resistenza e un microcontrollore da pochi euro. Il problema non è prendere un impulso, è non perderne nemmeno uno per settimane — un conteggio che sbaglia di tre litri al giorno, dopo un mese, è un numero inventato.

Perché la via facile perde i colpi

La prima versione la scrivono tutti con attachInterrupt(): una funzione che il microcontrollore chiama a ogni fronte del segnale e che incrementa una variabile. Un interrupt è appunto una interruzione: il processore smette quello che stava facendo e corre a eseguire quella funzione. Sembra istantaneo, ma non è garantito. Sull'ESP32 il driver Wi-Fi e il sistema in tempo reale che sta sotto Arduino si muovono negli stessi spazi: ci sono momenti in cui le interruzioni sono disabilitate, e un impulso che arriva proprio lì è perso.

Il problema opposto pesa di più: il conteggio ne vede troppi. Un contatto meccanico, come la lamella di un sensore reed dentro il contatore, non si chiude una volta sola: rimbalza, e l'interruzione vede cinque impulsi dove di litri ce n'è uno. Si può filtrare in software, ignorando i fronti troppo vicini al precedente, ma la soglia che toglie i rimbalzi toglie anche i consumi veloci veri.

Otto contatori che lavorano da soli

L'ESP32 ha una periferica dedicata. Una periferica è un pezzo di circuito dentro il chip che fa un lavoro suo, in parallelo al processore e senza chiedergli niente. Il datasheet della serie ne elenca al paragrafo 4.8.7 «Eight independent pulse counter units», e la documentazione del driver: «The PCNT (Pulse Counter) module is designed to count the number of rising and/or falling edges of input signals». Il conteggio avviene nel silicio: nessuna funzione può arrivare tardi, perché non c'è nessuna funzione.

Da sapere: le API Arduino non coprono questa periferica. La pagina del core Arduino dedicata al Pulse Counter contiene una riga sola: «This peripheral is not supported yet by the Arduino API's». Si usa quindi il driver di ESP-IDF, il framework nativo di Espressif, e la documentazione dichiara il core Arduino 3.3.12 «based on ESP-IDF 5.5», quindi il driver è già in ciò che l'ambiente installa e l'intestazione driver/pulse_cnt.h si include nello sketch.

Il filtro anti-disturbo non è un antirimbalzo

Il PCNT ha un filtro integrato, e qui si annida l'equivoco che fa buttare via settimane di dati. Si configura con un campo solo, max_glitch_ns: «Pulse width smaller than this threshold will be treated as glitch and ignored, in the unit of ns». Gli impulsi più stretti della soglia vengono scartati: sembra l'antirimbalzo che serve.

Non lo è. L'unità di misura è il nanosecondo, e la documentazione precisa che «the glitch filter operates using the APB clock» e che «the maximum glitch width should be longer than one APB_CLK cycle (typically 12.5 ns if APB is 80 MHz)». Lavora quindi nell'ordine dei nanosecondi: è fatto per i disturbi elettrici, le creste che un relè vicino induce sul filo, non per il rimbalzo di una lamella metallica. Il filtro si imposta comunque, perché togliere il rumore elettrico è gratis; i rimbalzi si tolgono altrove, con un filtro RC e una porta a soglia netta o con un sensore senza contatti, e il tempo di rimbalzo lo dichiara il datasheet del componente. Quest'ultimo passaggio è ragionamento: di contatti meccanici la documentazione del filtro non parla mai.

Chi progetta un nodo a batteria ha un'avvertenza in più: «When power management is enabled (i.e., CONFIG_PM_ENABLE is on), the system adjusts the APB frequency before entering light sleep, which can cause the PCNT glitch filter to misinterpret valid signals as noise». Il filtro conta cicli di quel clock: se la frequenza cambia, cambia la soglia.

Il tetto dei 16 bit

Il contatore hardware non è grande: «16 bit at most, defined in the hardware». I limiti si scelgono in configurazione, e il comportamento al superamento è dichiarato: «The counter will reset to zero automatically when it crosses either the high or low limit». Si azzera da solo, senza avvisare nessuno, e chi legge soltanto il registro vede il conteggio tornare indietro. Quanto presto succede si calcola: ho eseguito nel contenitore di generazione, con Python 3.13.16, una simulazione della sola aritmetica del contatore — nessun hardware — con limite 32767, il massimo per 16 bit con segno. Output reale:

   32767 impulsi reali -> registro  32767 | accumulato    32767
   32768 impulsi reali -> registro      0 | accumulato    32768
   40000 impulsi reali -> registro   7232 | accumulato    40000
 1000000 impulsi reali -> registro  16960 | accumulato  1000000

a   500 impulsi/ora il registro si azzera dopo     65.5 ore (   2.7 giorni)
a  2000 impulsi/ora il registro si azzera dopo     16.4 ore (   0.7 giorni)

Un'utenza da cinquecento litri al giorno manda il registro a zero ogni due giorni e mezzo. La soluzione documentata è il watch point, un valore di guardia: si dichiara al driver che al raggiungimento del limite deve chiamare una funzione, e lì si somma il limite a un totale a 64 bit. Dopo averlo aggiunto va azzerato il contatore perché abbia effetto, e i watch point disponibili sono pochi. C'è anche la scorciatoia accum_count, che fa l'accumulo nel driver — «This is helpful when you want to extend the counter's width» — ma la pagina di riferimento appiattisce le strutture annidate, quindi va controllato nell'intestazione installata se vada scritta dentro flags.

Due dettagli che fanno contare spazzatura

Il primo è scritto nella documentazione e viene ignorato sistematicamente: il driver non tocca le resistenze di tiraggio. I GPIO coinvolti «can be reconfigured for pull-up or pull-down after initializing PCNT», con gpio_pullup_en() o gpio_set_pull_mode(). Senza tiraggio un ingresso collegato a un contatto aperto è elettricamente in aria e raccoglie qualunque cosa passi: il conteggio sale da solo, con il contatore fermo. Sintomo numero uno, causa una riga mancante.

Il secondo è la scelta del piedino, perché non tutti accettano quella riga: «GPIO34-39 can only be set as input mode and do not have software-enabled pullup or pulldown functions», e su quei sei la resistenza va messa fuori, sulla basetta; «GPIO6-11 and GPIO16-17 are usually connected to the SPI flash and PSRAM integrated on the module», quindi vanno lasciati in pace. Un piedino di uso generale nella fascia media — GPIO27 qui sotto — evita entrambi i problemi.

Il collegamento, piedino per piedino

L'uscita a impulsi di un contatore è normalmente un contatto libero da potenziale: due morsetti che si chiudono fra loro, senza tensione propria, perché dentro c'è un reed o un fotoaccoppiatore. Un morsetto va al piedino marcato GND, l'altro a GPIO27; fra GPIO27 e il piedino 3V3 va la resistenza di tiraggio, e quella interna basta. A contatto aperto l'ingresso legge 3,3 V, a contatto chiuso zero: l'impulso è il fronte di discesa. Il datasheet dichiara l'alimentazione a 3,3 V tipici (campo 3,0–3,6 V) e, fra i valori massimi assoluti, una tensione di ingresso di 3,6 V: niente 5 V sui piedini. Se l'uscita del contatore o di un sensore interposto lavora a 5 V, fra i due ci va un adattatore di livello o almeno un partitore dimensionato, mai un filo diretto.

Avvertenze, e una dichiarazione

Non aprire il contatore e non toccare la morsettiera di un contatore di energia installato: quei morsetti possono essere vicini a conduttori in tensione di rete, l'intervento è di competenza del distributore e i sigilli spesso lo vietano. Si usa l'uscita a impulsi già portata all'esterno, e solo quella. In quadro, l'alimentatore va scelto con isolamento dichiarato e la scheda chiusa in custodia.

Il codice che segue è scritto sulla documentazione ESP-IDF 5.5.5 per l'ESP32 classico ed è coerente con le firme pubblicate, ma non è stato compilato né provato su hardware: l'ambiente in cui è stato preparato non ha scheda né catena di compilazione.

Lo sketch

Le azioni sui due fronti sono costanti separate: si incrementa sulla discesa, si tiene fermo sulla salita. Il segnale di controllo della direzione non serve e il suo piedino si dichiara -1 («Set to -1 if unused»); il limite basso deve essere negativo anche se non si usa.

#include "driver/pulse_cnt.h"
#include "driver/gpio.h"

#define PIN_IMPULSI   27
#define LIMITE_ALTO   1000
#define LIMITE_BASSO  -1
#define FILTRO_NS     1000

static pcnt_unit_handle_t unita = NULL;
static volatile int64_t accumulati = 0;

/* Chiamato in contesto di interruzione quando il contatore
   raggiunge il limite e si azzera da solo. */
static bool IRAM_ATTR al_limite(pcnt_unit_handle_t u,
                                const pcnt_watch_event_data_t *ev,
                                void *ctx)
{
    accumulati += ev->watch_point_value;
    return false;   /* nessuna task da risvegliare */
}

void avvia_contatore(void)
{
    pcnt_unit_config_t cfg_unita = {
        .high_limit = LIMITE_ALTO,
        .low_limit  = LIMITE_BASSO,
    };
    ESP_ERROR_CHECK(pcnt_new_unit(&cfg_unita, &unita));

    pcnt_glitch_filter_config_t cfg_filtro = {
        .max_glitch_ns = FILTRO_NS,
    };
    ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(unita, &cfg_filtro));

    pcnt_chan_config_t cfg_canale = {
        .edge_gpio_num  = PIN_IMPULSI,
        .level_gpio_num = -1,
    };
    pcnt_channel_handle_t canale = NULL;
    ESP_ERROR_CHECK(pcnt_new_channel(unita, &cfg_canale, &canale));

    /* salita: niente — discesa: conta */
    ESP_ERROR_CHECK(pcnt_channel_set_edge_action(canale,
        PCNT_CHANNEL_EDGE_ACTION_HOLD,
        PCNT_CHANNEL_EDGE_ACTION_INCREASE));

    pcnt_event_callbacks_t richiami = { .on_reach = al_limite };
    ESP_ERROR_CHECK(pcnt_unit_register_event_callbacks(unita, &richiami, NULL));
    ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unita, LIMITE_ALTO));

    gpio_set_pull_mode(PIN_IMPULSI, GPIO_PULLUP_ONLY);

    ESP_ERROR_CHECK(pcnt_unit_enable(unita));
    ESP_ERROR_CHECK(pcnt_unit_clear_count(unita));
    ESP_ERROR_CHECK(pcnt_unit_start(unita));
}

int64_t leggi_totale(void)
{
    int parziale = 0;
    ESP_ERROR_CHECK(pcnt_unit_get_count(unita, &parziale));
    return accumulati + parziale;
}

Filtro e registrazione del richiamo vanno fatti con l'unità ancora nello stato iniziale, prima di pcnt_unit_enable(): è una condizione della documentazione, non una preferenza di stile. E una nota onesta su leggi_totale(): lettura del registro e somma dell'accumulato non sono atomiche, quindi un superamento del limite esattamente in quel punto sbaglia di un giro. Con un contatore domestico la finestra è di microsecondi su decine di minuti.

Cosa puoi fare

Se un contaimpulsi è già in funzione la verifica non costa niente: segna il valore del dispositivo e quello del quadrante, aspetta una settimana e rifà il confronto. Se il tuo è più alto stai contando rimbalzi; se è più basso stai perdendo interruzioni oppure leggi un registro azzerato. Tre diagnosi diverse, e il segno della differenza le distingue subito.

Se devi ancora costruirlo: un piedino di uso generale fuori dalle fasce riservate, il tiraggio abilitato dove configuri il canale, il filtro a qualche centinaio di nanosecondi, il watch point sul limite alto, il totale a 64 bit. Poi il datasheet del sensore dentro il contatore, per il tempo di rimbalzo e per decidere se serve un filtro sul filo. Mezza giornata, contro il mese che altrimenti serve per scoprire che i numeri non tornano.