lunedì 18 luglio 2022

Termostato con DHT22

Questo articolo dimostra come implementare un termostato impiegando il sensore di temperatura DHT22. L'articolo precedente (questo) descrive un termostato basato su una sonda di temperatura NTC. La domanda mi viene spontanea, quanto codice del precedente articolo posso riusare?

Una caratteristica qualitativa del codice è la riusabilità, se nel precedente articolo ho scritto codice di qualità eccellente mi devo aspettare una riusabilità eccellente.

Il motto è scrivi una sola volta il codice e riusalo tutte le volte che serve. Il codice scritto nel precedente articolo non è eccellente ma è riusabile e questa non è una caratteristica da sottovalutare. 

Sicuramente la funzione checkTermostat() è riusabile come pure la parte di codice presente nella funzione loop() che mi permette di regolare il setpoint tramite il potenziometro. Le funzioni analog16samples() e getTemperature() non servono più e vengono quindi rimosse. Di seguito la funzione loop() modificata per lavorare con il DHT22:

void loop() {
// Non possiamo leggere il sensore ad intervalli
// di tempo inferiori di 2 secondi.
if (millis() - g_timer2sec >= 2000) {
g_timer2sec = millis();
int chk = dht22.read22(DHTPIN);
if (chk == DHTLIB_OK) {
dtostrf(dht22.temperature, 6, 1, temperatureBuffer);
lcd.setCursor(0, 1);
lcd.print(temperatureBuffer);
// da floart x 10 a int16_t
g_temperature = dht22.temperature * 10;
// chiama la funzione termostato
bool thState = checkThermostate(digitalRead(g_relayPin));
digitalWrite(g_relayPin, thState);
// visualizza ON/OFF sul display
lcd.setCursor(10, 0);
lcd.print(onoff[thState]);
} else {
// qui la gestione degli errori relativi al DHT22
}
}
// usa struttura di supporto
mySetpoint._old = analogRead(A1);
if (mySetpoint._old != mySetpoint._new) {
mySetpoint._new = mySetpoint._old;
float stpf = mySetpoint._new / 13.3;
g_setpoint = stpf * 10; // da float x 10 a uint16_t
dtostrf(stpf, 6, 1, temperatureBuffer);
lcd.setCursor(0, 0);
lcd.print(temperatureBuffer);
}
} // end void loop()

Nel loop ci serve eseguire del codice con temporizzazione di 2 secondi, a questo serve la if (millis() - g_timer2sec .... Ogni due secondi interroghiamo il DHT22, la temperatura viene visualizzata sul display, poi convertita a int16_t e il valore salvato nella variabile g_temperature. Il resto del codice è quasi uguale a quello presente nel precedente articolo.

Per la conversione a int16_t moltiplichiamo per 10 (es. 24.1°C x 10 = 241), il risultato della moltiplicazione viene assegnato alla variabile g_temperature e la conversione da tipo float a tipo int16_t viene fatta magicamente dal compilatore.

Moltiplichiamo per 10 poiché il sensore ha risoluzione di 0.1°C (decimi di grado), mentre con il sensore NTC moltiplicavamo per 100 così da avere risoluzione di 0.01°C (centesimi di grado).

  • Nota sul relay: Il relay presente nel progetto è connesso direttamente ad un pin di arduino, ciò è possibile solo con il simulatore, nella pratica non si fa per non guastare arduino.

Poiché moltiplichiamo la temperatura per 10, facciamo la stessa cosa con il setpoint e l'isteresi. La modifica per il setpoint si riduce a:

g_setpoint = stpf * 10; // da float x 10 a uint16_t

Come sempre, per concludere il link al progetto con il simulatore online wokwi.

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Condividi allo stesso modo 4.0 Internazionale

martedì 5 luglio 2022

Arduino sketch multi-file - dividere lo sketch

Come dividere lo sketch di arduino

Quando il numero di linee supera una certa soglia si inizia a sentire la necessità di dividere lo sketch in più file. Molto spesso si opta per più file .ino e/o un solo file .ino, più un header file .h, Purtroppo sono entrambe scelte infelici che anziché aiutare complicano lo sviluppo. Chi conosce bene sia il linguaggio C++ e la particolare fase di build usata da arduino IDE conosce come organizzare bene l'albero dei sorgenti.

Una buona suddivisione permette di sfruttare meglio la potenza delle compile unit del C++. Un modulo .cpp è per il compilatore una compile unit indipendente dalle altre. La compile unit viene compilata dal compilatore il quale genera un file oggetto .o.

Più compile unit (quindi più file .cpp) riceveranno lo stesso trattamento, ognuna per essere compilata non necessità dell'altra. Nota che il file .ino è una compile unit anch'esso. Ad esempio da tre file .cpp più il file .ino, il compilatore genera quattro file .o, questi vengono analizzati dal linker (ld) nella fase finale di collegamento e trasformati in un unico file con estensione .elf. Ma attenzione da tre file .ino si ottiene un solo file oggetto e quindi non sono tre compile unit.

Creare più compile unit

L'ide arduino permette di creare più file appartenenti allo stesso progetto tramite il pulsante a destra sotto la lente di ingrandimento. Premendolo compare un menu dove il primo elemento è "Nuova Scheda", selezionandola viene richiesto il nome del nuovo file che desideriamo creare.

Se vogliamo che il nuovo file si chiami pippo lo scriviamo e confermiamo con il pulsante OK, di conseguenza compare una tab vuota di nome pippo. Se premiamo il pulsante "Salva" di default viene creato un file di nome pippo.ino e questo non ci serve poiché vogliamo creare una compile unit .cpp. Per ottenere ciò oltre al nome del file dobbiamo specificare anche l'estensione, cioè così: pippo.cpp. Possiamo anche specificare come estensione .c, .h e .S. Il modulo .c può contenere solo codice sorgente compatibile con il linguaggio C (non C++) e il modulo .S può contenere solo codice assembly.

Vediamo di seguito un possibile albero dei sorgenti:

sketch.ino
moduloa.cpp
modulob.cpp
moduloc.cpp
config.h
extern.h

 

Variabili e funzioni

Tutte le variabili usate solo da un modulo vengono dichiarate nel modulo stesso anteponendo la parola chiave static alla dichiarazione. Stessa cosa per le funzioni. Il modulo funge da contenitore in modo simile ad una classe, similmente ciò che è static di modulo sono membri privati visibili solo al modulo in cui sono state dichiarate/definite.

Per le funzioni definite in un modulo, ma che vogliamo rendere visibili (accesso pubblico) in altre compile unit, abbiamo due strade:

  • Un header file per modulo contenente i prototipi delle funzioni che vogliamo rendere pubbliche.
  • Un header file (extern.h contenete tutte le funzioni e variabili che vogliamo rendere pubbliche, anteponendo alla dichiarazione/prototipo la parola chiave extern.

Se il programma è molto complesso e diviso in numerose compile unit si preferisce avere un header file per modulo .cpp.

Ogni file .h avrà lo stesso nome del file .cpp, ad esempio il modulo motorController.cpp ha un file header di nome motorController.h che contiene le dichiarazioni dei prototipi di funzione che vogliamo rendere pubblici. Nessuno dei file .h dovrà contenere dichiarazioni di variabili, queste saranno sempre all'interno dei moduli .cpp. Ricordiamo che dichiarare una variabile comporta allocazione di memoria ram da parte del compilatore.

Iniziamo con solo due file, un .ino e un .cpp, io ho dato questi due nomi: sketch.ino e modulea.cpp. Il nostro primo obbiettivo è dichiarare una variabile nel .ino, che sia visibile anche nel file modulea.cpp.

// file sketch.ino
 
int ininofile = 255;
extern void printInInoFile();
 
void setup() {
Serial.begin(115200);

} // end void setup()
void loop() {

}

Nel file .cpp invece definisco una funzione che stampa il valore di ininofile, funzione che deve essere visibile dal file .ino. Quella riga che inizia con extern ha il compito di importare la visibilità della funzione definita nel file .cpp. Vediamo adesso il .cpp:

// Se il modulo usa le api di arduino serve includere Arduino.h
#include <Arduino.h>

extern int ininofile;
 
void printInInoFile() {
Serial.println(ininofile);
}

Stessa cosa, la riga che inizia con externimporta la visibilità della variabile ininofile dichiarata nel file .ino. Nota l'inclusione di Arduino.h, essa è necessaria solo nel caso in cui nel modulo facciamo ricorso alle API arduino, cioè pinMode(), digitalWrite(), Serial ecc.

Ciò che ho fatto è solo un esempio di uso della parola chiave extern, pertanto l'incrocio tra funzione e variabile non è da prendere come buono esempio di programmazione, capito ciò ora sappiamo come rendere visibile una funzione o variabile ad un modulo diverso da quello in cui è definita o dichiarata la variabile.

Quando il processo di compilazione non va a buon fine per una variabile o funzione mancante, il compilatore (o meglio il linker ld) emette l'errore seguente:

/sketch/modulea.cpp:39: undefined reference to `ininofile'
/sketch/modulea.cpp:39: undefined reference to `ininofile'
collect2: error: ld returned 1 exit status Error during build: exit status 1

Il compilatore cerca la dichiarazione della variabile ininofile e non la trova (l'ho commentata io per mostrare l'errore), stessa cosa quando non trova la definizione di una funzione. Tradotto sarebbe: "riferimento non definito per ininofile".

La penultima riga recita "collect2: ecc", sappiamo che ld è il linker e quindi visti i messaggi precedenti, concludiamo che nella fase di collegamento ad opera del linker qualcosa non è andato per il verso giusto e ld termina con errore 1.

Quando si usa un header file (i file .h) è bene non dichiarare variabili al loro interno. Quindi dichiarare le variabili o nel file .ino o nel modulo .cpp.

Subito ci si chiede, perché non dovrei se è consentito?

Anche se è consentito, due moduli che includono lo stesso header file contenente una dichiarazione di variabile verrebbe vista dal compilatore come due dichiarazioni di variabile con lo stesso nome, e il compilatore termina con errore.

In un header file ci vanno solo prototipi di funzione e macro del preprocessore, come ad esempio #define.

Un header file deve sempre avere una protezione per evitare che il file venga incluso due volte nella stessa compile unit. Conosco due modi per fare ciò: usare la direttiva #ifndef o la direttiva #pragma once. Di seguito il file config.h:

#ifndef config_h
#define config_h

// non è permesso dichiarare qui variabili.
#define HAS_RTC true
#define HAS NTC true

#endif

Se questo file viene incluso in più moduli (incluso .ino) questi avranno accesso alle due macro HAS_RTC e HAS_NTC.

Come al solito il link al progetto su wokwi, ma stavolta solo alcune raccomandazioni: i nomi di funzioni e variabili sono specificamente pensati per aiutare a seguire il codice. Le funzioni non fanno nulla di apparentemente utile e si limitano a mostrare che extern funziona come spiegato.

Riassumendo

  1. Non dichiarare variabili in un header file.
  2. Usa static di modulo quando la variabile o funzione non deve essere visibile in altri moduli, incluso il file .ino,
  3. Usa extern saggiamente, preferisci sempre un header file con la lista dei prototipi di funzione.
  4. Proteggi sempre un header file dalla inclusione multipla accidentale.
  5. Per accedere ai membri privati usa i metodi get e set.
  6. Includi nel modulo .cpp Arduino.h solo se necessario.

Importare una libreria

Grazie alla possibilità di creare più compile unit possiamo importare una libreria in locale al fine di adattarla e configurarla come meglio ci necessità. Oppure all'inverso creiamo in locale una libreria che poi esportiamo. Importare ed esportare in questo caso deve essere fatto manualmente, copiando il file.

I motivi per modificare una libreria sono diversi, uno di questi potrebbe essere che temiamo che la libreria in questione in futuro venga modificata in modo tale da non essere più compatibile con il nostro sketch. Questo è già un buon motivo, in ogni caso l'obbiettivo qui è mostrare come importare in locale una libreria e come svilupparne una ed esportarla e per farlo dobbiamo scegliere una libreria da importare.

Scegliamo la libreria LiquidCrystal standard già presente nella cartella:

arduino-1.8.19/libraries/LiquidCrystal/src

Da questa cartella copiamo i due file: LiquidCrystal.h e LiquidCrystal.cpp

Ci spostiamo nel percorso:

arduino-1.8.19/portable/sketchbook

dove creiamo una cartella di nome: importLiquidCrystal

All'interno di questa cartella incolliamo i file copiati prima. Sempre in questa cartella creiamo con un editor esterno un file vuoto di nome:

importLiquidCrystal.ino

Se abbiamo fatto tutto correttamente ci ritroviamo come appare qui sotto.

portable/sketchbook/importLiquidCrystal:
.  ..  
importLiquidCrystal.ino
LiquidCrystal.cpp
LiquidCrystal.h

Adesso con Arduino IDE apriamo il file importLiquidCrystal.ino e magicamente si apriranno due tab contenenti gli altri due file. Ora possiamo apportare modifiche senza compromettere la libreria LiquidCrystal standard che è sempre disponibile. Apportiamo un modifica alla funzione setCursor() in modo che come primo argomento prenda la riga (row) e secondo argomento la colonna (col). La modifica è semplice, basta invertire gli argomenti in entrambe i file LiquidCrystal.h e LiquidCrystal.cpp.

La modifica al file LiquidCrystal.cpp:

1
void LiquidCrystal::setCursor(uint8_t row, uint8_t col)

La modifica al file LiquidCrystal.h:

1
void setCursor(uint8_t row, uint8_t col);

Adesso possiamo testare la nostra modifica alla LiquidCrystal. Spostiamoci nel primo tab che mostra il file importLiquidCrystal.ino vuoto e ci scriviamo ciò che compare di seguito:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include "LiquidCrystal.h"

LiquidCrystal lcd(12, 11, 10, 9, 8, 7);

void setup() {
  Serial.begin(115200);
  lcd.begin(16, 2);
  lcd.setCursor(0, 2);
  lcd.print("Hello!");
}

void loop() {
  
}

La scritta Hello! dovrà comparire alla riga 0 e colonna 2, come nella figura sotto. Notate che alla riga 1, al posto di < e > ci sono gli apici, questa sintassi è da usare per i file locali e il file LiquidCrystal.h lo è. Adesso siamo in grado di apportare tutte le modifiche che desideriamo, ad esempio io invertirei anche i primi due argomenti della funzione LiquidCrystal::begin().

Dopo le modifiche potrebbe venirci voglia di trasformare la nuova LiquidCrystal in libreria, qui sorgono i primi problemi, poiché esiste già una libreria con questo nome, ma per adesso facciamo finta che la libreria l'abbiamo sviluppata noi e che quindi avrà nomi di file e di classi da non potere andare in conflitto con una libreria già installata. Una libreria arduino per essere conforme e compatibile con arduino IDE deve avere una specifica struttura che possiamo dedurre da altre librerie, ma l'argomento richiede un articolo a parte.

Per adesso il nostro obbiettivo è di organizzare meglio l'albero dei sorgenti. Per progetti complessi potremmo desiderare la suddivisione dell'albero dei sorgenti in cartelle separate, ma purtroppo non è possibile seguendo l'intuito che ci suggerisce il seguente albero dei sorgenti:

importLiquidCrystal
LiquidCrystal
LiquidCrystal.h
LiquidCrystal.cpp
MTRelay
MTRelay.h
MTRelay.cpp
AnotherLib
AnotherLib.h
AnotherLib.cpp
importLiquidCrystal.ino

La compilazione termina con un errore che indica la mancata compilazione dei moduli .cpp, ma basta un piccola modifica e tutto fila liscio. L'albero dei sorgenti seguente infatti compila correttamente, come corretta è l'esecuzione sulla scheda:

importLiquidCrystal
src
LiquidCrystal
LiquidCrystal.h
LiquidCrystal.cpp
MTRelay
MTRelay.h
MTRelay.cpp
AnotherLib
AnotherLib.h
AnotherLib.cpp
importLiquidCrystal.ino

L'aggiunta della cartella src è determinante e funziona anche copiandovi una o più librerie prese dalla cartella: arduino-1.8.19/libraries. Funziona anche con librerie prelevate da github, questo ci da la possibilità di testare librerie senza doverle per forza installare. Purtroppo non è possibile aprire un tab per potere editare uno dei file sotto le cartelle, per editarlo siamo costretti ad usare un editor esterno. Per includere un header file dobbiamo specificare il percorso relativo, ad esempio il file .ino conterrà: #include "src/MTRelay/MTRelay.h".

Conclusione

Quanto descritto in questo articolo ha l'obbiettivo di semplificare la fase di sviluppo mantenendo l'albero dei sorgenti facile da creare e estendere. Certamente non ho detto tutto quello che c'è da sapere in merito, ma spero di non avere tralasciato nulla di così determinante da rendere nullo l'intento.

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Condividi allo stesso modo 4.0 Internazionale

venerdì 1 luglio 2022

Termostato con arduino (2° puntata)

Neanche fosse un telefilm, ma in realtà è corretto poiché di articoli sull'argomento ne servirebbero davvero tanti, e ognuno sarebbe come le ciliegine, una tira l'altra.

Un termostato digitale può essere davvero complesso, se pensiamo alla gestione di una cella frigorifera negativa, abbiamo da controllare:

  • Un compressore da circa 2kW di potenza.
  • Un elettro-ventilatore da circa 20W.
  • Una elettrovalvola inversione ciclo.
  • Un interruttore o sensore porta.
  • Un lampada per illuminazione interna.

Come minimo ci servono almeno 4 relay (quindi 4 digital pin), un ingresso digitale per il sensore porta, 2 sonde di temperatura: una sonda montata nell'evaporatore e l'altra come sonda ambiente. Non è finita qui ovviamente è obbligatorio avere la possibilità di interagire con il controllore, quindi ci servono: display e pulsanti. Direte voi è finita qui, potrebbe finire qui ma anche no. Considerate che i prodotti conservati hanno un costo molto elevato, vogliamo garanzie che il prodotto non si deteriori a causa di un guasto, ci serve qualcuno che monitori la cella in remoto 24 ore su 24. Allora il controllore deve potersi connettere ad una rete per poterlo interrogare da remoto, ecco che da un semplice termostato siamo arrivati ad una moderna centralina per la gestione di una cella frigorifera. 

Bene ora torniamo con i piedi per terra e rileggiamo l'articolo precedente, dove ho scritto che un termostato elettromeccanico è un interruttore controllato dalla temperatura. Invece nel caso del termostato digitale, esso di base è un relay controllato dalla temperatura. Non è un caso che qui io abbia scritto una classe Relay. 

Dimenticavo alla fine dell'articolo precedente ho lasciato un abbozzo di funzione e adesso come promesso devo descriverla. La riporto qui di seguito per comodità:

bool checkThermostat(bool state) {
switch (state) {
case LOW:
if (g_temperature <= g_setpoint - g_isteresi) {
state = HIGH;
}
break;
case HIGH:
if (g_temperature > g_setpoint) {
state = LOW;
}
break;
} // end switch (state)
return state;
} // end checkThermostat()

Coloro i quali hanno letto altri miei articoli sanno già che per i nomi delle variabili globali uso il prefisso g_ e da ciò dovrebbe essere facile dedurre che la funzione necessità di 3 variabili globali di seguito descritte:

  • g_temperature - questa variabile contiene il valore di temperatura rilevato dalla sonda, poco importa il tipo di sonda, ciò che conta è che è di tipo float. Quasi certamente desideriamo che il suo valore sia visibile su un display.

  • g_setpoint - questa variabile contiene il valore di temperatura desiderata e il tipo è sempre float. Anche questo lo visualizziamo su un display e inoltre dobbiamo poterne modificare il valore tramite dei tasti o altro. Ancora un desiderio quasi una necessità è quella di salvare il valore in eeprom in modo che spegnendo e riaccendendo il controller usi il valore plausibile usato in precedenza.

  • g_isteresi - come sopra, cioè modificabile e salvato in eeprom, sempre di tipo  float.

Rimane da capire a cosa serve l'argomento state usato sia in ingresso che in uscita.Se il relay è connesso al pin 13 la chiamata a funzione sarà qualcosa di simile:
 
void loop() {
     bool state = checkThermostat(digitalRead(13));
     digitalWrite(13, state); 
} 

Oppure semplificando al massimo si elimina la variabile state così:
 
void loop() {
    digitalWrite(13, checkThermostat(digitalRead(13))); 
} 

Senza la variabile state però non possiamo visualizzare lo stato del pin 13 sul display. La funzione svolge correttamente il proprio compito, ma non è mai buona idea fare > o < (== ancora peggio) tra variabili di tipo float. Il perché è legato al modo in cui un valore in virgola mobile è conservato in memoria nel formato binario. Possiamo farci una idea seguendo questo link dove nel campo You entered digitiamo 49.02 e nel campo sotto vediamo che il valore effettivo vale 49.020000457763671875, ci sono ben 18 numeri a destra del punto decimale, decisamente troppi. La CPU deve eseguire molte istruzioni elementari e il tempo CPU è prezioso e non va sprecato. C'è anche da dire che la temperatura rilevata da una sonda anche precisa non va oltre i 2 decimali e questo significa prendere in considerazione i centesimi di grado centigrado. 

La nostra funzione allora potrebbe lavorare meglio con variabili di tipo intere grandi 16 bit ciascuna. Possiamo realizzare ciò usando il tipo int16_t al posto di float per le 3 variabili globali. 

Il tipo int16_t può rappresentare un valore decimale nel seguente intervallo: -32768÷32767. Questo vuole dire che se la temperatura rilevata è di 327.67 °C posso ancora convertirlo in intero a 16 bit moltiplicando per 100 che appunto vale 32767. Per avere margine se il termostato deve lavorare con temperature pari o superiori a 327.67 °C al posto di int16_t userò il tipo int32_t. Se ci accontentiamo dei decimi di grado possiamo moltiplicare per 10 e useremo sempre int16_t così la nostra funzione lavorerà fino a poco meno di 3276.0 °C.

Test al simulatore

-

Figura 1: Collegamenti del termostato simulato con wokwi

La Figura 1, mostra i componenti e il loro collegamento, li elenco di seguito per commentarli: 

  • Display - lo impieghiamo per mostrare la temperatura letta dalla sonda NTC (23.99) e la temperatura del setpoint (49.02) impostata tramite il potenziometro. La scritta ON visibile si trasforma in OFF e indica lo stato del relay.

  • Arduino -  il pin 13 è direttamente connesso alla bobina del relay, possiamo fare ciò solo con il simulatore, nella pratica si guasta arduino. Il pin A0 è connesso alla sonda di temperatura NTC. A1 è connesso al pin centrale del potenziometro che ci serve per regolare il setpoint.

  • Potenziometro - Nella pratica è sufficiente un potenziometro lineare da 10k. Aggiungere un condensatore da 100nF tra A1 e GND rende più stabile il valore acquisito dal convertitore ADC.

  • Led - si accende quando i contatti NO del relay si chiudono.

  • Relay - Abbiamo già detto che nella pratica il relay non si può collegare direttamente ad un pin di arduino. Nella pratica i contatti NO ci servono per alimentare la resistenza di riscaldamento, noi per la simulazione ci abbiamo messo il led.

  • NTC - Negative Temperature Coefficient. Si tratta di un resistore il cui valore in ohm varia in base alla sua (del resistore) temperatura. In commercio si trovano schedine simili a quelle in Figura 1, le quali possono essere collegate direttamente ad arduino. Per ricavare il valore di temperatura espresso in gradi centigradi servono un poco di calcoli, io ho usato il codice descritto qui e ne ho ricavato una funzione. 

La funzione per leggere la temperatura

Come anticipato, la funzione per calcolare la temperatura rilevata dalla sonda NTC l'ho ricavata dal tutorial di adafruit, la riporto di seguito:

float getTemperature() {
float average = analogRead(A0);
average = 1023 / average - 1;
average = 10000.0 / average;
//float steinhart;
average = average / 10000.0; // (R/Ro)
average = log(average); // ln(R/Ro)
average /= 3950; // 1/B * ln(R/Ro)
average += 1.0 / (25 + 273.15); // + (1/To)
average = 1.0 / average; // Invert
average -= 273.15;
return average;
}

Si tratta di una semplificazione, in quanto normalmente si acquisiscono 8, 16 (o più) campioni in un secondo (cioè uno ogni 1/16 = 62.5ms) e di questi si fa la media aritmetica.

Cioè i 16 campioni si sommano e poi si divide per il numero di campioni, 16 in questo caso. 

Al simulatore non c'è necessità di effettuare la media aritmetica per cui ho semplificato il codice.

Avviando la simulazione e cliccando sul sensore NTC compare un cursore con il quale variare la temperatura rilevata dalla sonda. Con la rotellina del mouse posso incrementare/decrementare a passi di 0.1°C. Questo ci da la possibilità di verificare il funzionamento del termostato, isteresi inclusa.

La funzione per la media

La lettura dal canale analogico è sempre soggetta ad inquinamento elettromagnetico, per cui leggiamo si il valore della sonda di temperatura ma oltre a questo leggiamo le emissioni elettromagnetiche che qualunque elettrodomestico emette sia nella rete elettrica che attorno ad esso. Tutti gli elettrodomestici per normativa devono emettere meno inquinamento elettromagnetico possibile, ma tecnicamente è impossibile azzerarlo. Per cui commutazioni di carichi importanti, come condizionatori (ON/OFF) ecc rendono la lettura della sonda instabile, avremo un valore che oscilla attorno ad un valore medio. Dall'altro canto, dispositivi elettronici sensibili ai disturbi elettromagnetici si difendono attraverso filtri passivi e algoritmi di acquisizione, con l'obbiettivo di ricavare il segnale utile meno inquinato possibile. Ma anche qui è impossibile renderlo totalmente immune.

L'algoritmo di seguito descritto è il più semplice tra gli algoritmi usabili per filtrare i disturbi, tuttavia abbinato a filtri hardware risulta fornire letture sufficiente stabili.

uint16_t analog16Samples() {
static uint16_t somma;
static uint32_t timer62ms;
static byte nSamples;

if (millis() - timer62ms >= 62) {
timer62ms = millis();
somma += analogRead(A0); // accumula i campioni
nSamples++;
if (nSamples == 16) {
nSamples = 0;
uint16_t media = somma / 16; // calcola la media
somma = 0;
return media; // restituisce la media
}
}
return 0;
}

La funzione restituisce zero per tutte le chiamate da 0÷15, alla 16° chiamata restituisce la media aritmetica dei 16 campioni acquisiti dal pin A0, la cosa si ripete ogni 16 campioni. In breve avremo la media ogni 992ms (quasi 1 secondo). Sfrutteremo questo comportamento per eseguire in loop codice temporizzato.  

Il nome della funzione non dice da dove acquisisce i campioni, da lettore del programma per capire da dove provengono devo leggermi la funzione per scoprire che sono acquisiti dal pin A0. Per migliorare la leggibilità potrei usare il nome read16SamplesOnA0 o altro che sia più comprensibile e magari pure più sintetico, in sostanza serve tempo per trovare il giusto nome, ma quanto tempo dedicare dipende anche da altri fattori, ad esempio la funzione in questione non permette di scegliere da dove acquisire i campioni, e se avessi più sensori, come dovrei fare? 

Inoltre la funzione non fa qualcosa che potrebbe fare e anche per questo motivo non serve dedicargli altro tempo. In particolare la funzione potrebbe anche valutare la bontà della sonda NTC, sonde che hanno il brutto vizio di incamerare umidità e dopo un onorato servizio si devono sostituire preventivamente. Un termostato digitale oggi può permettersi di eseguire un algoritmo che valuti la bontà della sonda e ne segnali il possibile malfunzionamento. Basterebbe già un algoritmo che consideri il delta tra due campionamenti. Cioè, 1° campione 475, 2° campione 257, c'è un delta di  200 in 62ms, il ripetersi frequente di ciò può solo dipendere da un guasto alla sonda. Una sonda in corto o totalmente aperta è facile da verificare, ma al momento questo termostato digitale non ha questa capacità.

Quindi per adesso la funzione resta così com'è con i suoi pregi e difetti, ma attenzione a creare decine di funzioni con questi difetti. Il pregio è che non è "bloccante" nei confronti delle altre istruzioni presenti nella funzione loop() e non è un pregio da poco.

Se usiamo questa funzione dobbiamo modificare la funzione getTemperature() in modo che operi con il valore restituito da analogi16Samples(), la modifica si limita ad inserire nella lista degli argomenti una variabile di tipo uint16_t. Vediamo solo le prime due righe:

float getTemperature(uint16_t a0) {
float average = a0;
... 

Adesso però il nome della funzione non è calzante, poiché la funzione effettua solo dei calcoli in modo che dal valore nell'intervallo 0÷1023 ne risulti un valore di temperatura in virgola mobile, il nome potrebbe essere calcTemperature(), nome che è più coerente con il compito svolto dalla funzione.

Siamo quindi arrivati a mettere il tutto assieme, ciclicamente vengono eseguite queste operazioni così riassunte:

LOOP:
IS MEDIA() == READY) {
    T = CALC(MEDIA())
    LCD_PRINT(T)
    TH_STATE = CHECK_TH(T)
    LCD_PRINT(TH_STATE)
    RELAY(TH_STATE)
}
IS SETPOINT() == CHANGED {
    LCD_PRINT(setpoint)
}
GOTO LOOP

Pseudo linguaggio messo insieme in pochi istanti, impreciso e raffazzonato, non importa, qualunque cosa è buona se aiuta durante lo sviluppo. La sequenza però è corretta, per cui ha una valenza descrittiva.

È come se chiedessi ad una identità non precisata: quando hai una media pronta mi calcoli la temperatura, la stampi, esegui la funzione termostatica, stampi lo stato del termostato e comandi il relay?  Grazie.

Nel mentre che ricavi la media, se il setpoint è cambiato aggiorni il display? Grazie.

Potrei fare una richiesta simile a chiunque sia in grado di cucinare, ad esempio tortellini panna e prosciutto. Rosoli in padella il prosciutto, nel frattempo cuoci i tortellini. Scola i tortellini e unisci panna e prosciutto, mescola per 1 minuto a fuoco lento. Chi non ha mai cucinato non ha idea di cosa voglia dire "Rosolare", non sa che serve l'olio (o altro) e non ha idea di quanto tempo deve cucinare il prosciutto.

L'identità non precisata è capace di eseguire la seguente richiesta: quando hai una media pronta mi calcoli la temperatura e la stampi, perché essa conosce:

  • Da dove acquisire i dati.
  • Come calcolare la media.
  • Come dalla media calcolare la temperatura.
  • Come e dove stampare la temperatura.

Ha queste capacità perché abbiamo creato le funzioni di supporto. Ancora manca il cuoco (loop) che esegue la ricetta, si occuperà il loop() di eseguire la ricetta.

Bottom-up e Top-down

Sono metodologie di sviluppo ed analisi conosciute in vari campi, intraprese anche inconsciamente, esse condizionano pesantemente lo sviluppo. L'una viene descritta come l'opposto all'altra metodologia. Scrivere in merito all'argomento sarebbe per me troppo impegnativo, la cosa interessante che accade sempre con gli opposti e che si creano delle fazioni. Così è stato o così è stato descritto, cioè chi preferisce pensare dal basso verso l'alto crede sia il modo corretto e viceversa. Per fortuna si è capito che non c'è una metodologia sbagliata e una giusta, ma che entrambe sono valide e che non si deve scegliere, ma si devono usare entrambe, passando dall'una all'altra frequentemente. Per fortuna non devo scrivere altro sull'argomento poiché su wikipedia ho trovato l'argomento trattato in modo sintetico e vi invito a leggerlo.

Una citazione dal link: "Niklaus Wirth, che tra altre imprese sviluppò il linguaggio di programmazione Pascal, scrisse l'autorevole documento Lo sviluppo del software per raffinamenti successivi."

Per descrivere il software di questo termostato evidentemente (e inconsciamente) ho usato la metodologia Bottom-Up (dal basso verso l'alto). Mentre se avessi iniziato a mostrarvi il contenuto della funzione loop() avrei usato il metodo Top-Down. 

Ma come è possibile usare una metodologia quando non ci sono funzioni di supporto e la funzione loop() è vuota?

Si procede a piccoli passi fino ad avere del codice funzionante da inserire in una funzione che funga da punto cardine. Ho iniziato con le if presenti nella funzione checkThermostat(), una if per volta, al posto delle variabili ho usato le costanti numeriche, verificato il funzionamento ho usato delle variabili, il tutto all'interno della funzione setup(). Il codice aveva delle dipendenza da tre variabili globali di tipo flot, le ho create ed inizializzate con costanti, solo in seguito ho deciso che queste variabili dovevano essere di tipo int16_t, questo metodo non è altro che la rifinitura successiva continua che permetta all'algoritmo di essere affinato.

Inconsciamente ho scelto il metodo "dal basso verso l'alto". Invece lo pseudo linguaggio usato prima usa il metodo "dall'alto verso il basso", stessa cosa quando immagino di chiedere ad una entità non precisata di compiere delle azioni.

La funzione loop()

Siamo arrivati a vedere il contenuto della funzione loop() (il cuoco), mentre per coloro che hanno premura qui al solito il progetto al simulatore wokwi.

constexpr char *onoff[] = { "OFF", "ON " };
char temperatureBuffer[7];
// struttura di supporto
struct Setpoint {
uint16_t _old;
uint16_t _new = 1024; // new != old
};

Setpoint mySetpoint;


void loop() {
// a0 contiene una lettura aggiornata ogni 62ms x 16 = 992ms
uint16_t a0 = analog16Samples();
// entra nella if (a0 != 0) ogni 992ms
if (a0 != 0) {
Serial.println(millis());
// converte a0 in tf.
float tf = calcTemperature(a0);
// converte tf nella C string temperatureBuffer
dtostrf(tf, 6, 1, temperatureBuffer);
// tronca e converte tf in int16_t
g_temperature = tf * 100;
// visualizza la C string temperatureBuffer sul display
lcd.setCursor(0, 1);
lcd.print(temperatureBuffer);
// chiama la funzione termostato
bool thState = checkThermostate(digitalRead(g_relayPin));
digitalWrite(g_relayPin, thState);
// visualizza ON/OFF sul display
lcd.setCursor(10, 0);
lcd.print(onoff[thState]);
}

// usa struttura di supporto
mySetpoint._old = analogRead(A1);
if (mySetpoint._old != mySetpoint._new) {
mySetpoint._new = mySetpoint._old;
float stpf = mySetpoint._new / 13.3;
g_setpoint = stpf * 100; // da float a uint16_t
dtostrf(stpf, 6, 1, temperatureBuffer);
lcd.setCursor(0, 0);
lcd.print(temperatureBuffer);
}
} // end void loop()

In aggiunta al loop compaiono tre dichiarazioni e una definizione (la struct Setpoint), dove mySetpoint è variabile istanza di tipo Setpoint . Mentre onoff[] è un array di C string che uso per visualizzare lo stato del relay e temperatureBuffer viene usato in dtostrf(). Come recita il commento, nella prima if (... ci entra ogni circa 992ms. Mentre nella seconda if (... ci entra solo quando regoliamo il setpoint agendo sul potenziometro.

Per coloro i quali vogliono sincerarsi che la prima if (... venga davvero eseguita ogni circa 992ms consiglio di aggiungere la stampa di millis() nel serial monitor, così:

if (a0 != 0) {
Serial.println(millis());
// converte a0 in tf.
float tf = calcTemperature(a0);

Dovrebbe stampare (o meglio stampa) quanto segue:

993
1986
2979
3972
4965

Che è la conferma che il "circa 992" è in realtà ogni 993ms, ma Serial.println() si prende il suo tempo per stampare.

La terza puntata

La terza e non ultima puntata descrive due classi che semplificano ulteriormente la creazione di un termostato. Potremo avere fino a sei sensori di temperatura NTC e 6 termostati su arduino UNO.

Termostato con DHT22  

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Condividi allo stesso modo 4.0 Internazionale.