Automazione parking access
Come
dice una famosa canzone: “solo il pretesto per fare una canzone”, io vi
dico che l’applicazione qui descritta è solo il pretesto per fare un
programma. In effetti è proprio così, non ho particolare interesse per
il parking access, ma dovevo scegliere una applicazione pratica che si
potesse sviluppare con il simulatore online wokwi. L’attrazione
principale del programma è la macchina a stati finiti basata sullo
switch case. Nel precedente articolo mi ero riproposto di farcire i case
di codice implementando nuove caratteristiche, ad esempio implementare
il lampeggiante, oppure usare il display. Però avevo anche parlato di
implementare gli entry state ed exit state ed è bene che lo faccia prima di farcire i case
di codice. Allora vediamo cosa sono questi entry state o OnEntryState ed OnExitState.
Entry state ed exit state
Non
sempre si sente la necessità di eseguire del codice solo all’ingresso
di uno stato (o case), ma quando si scopre che, implementarli è
semplice, gli si trova utilità. Alcune applicazione necessitano di entry
state e quindi non se ne può fare a meno di implementarle. Mentre per
lo exit state è più difficile trovare una utilità, comunque scopriamo
cosa sono e come è semplice implementarli, usando l’applicazione già
sviluppata in precedenza, poi decidiamo se, come, e quando usarli. C'è
anche da considerare che l'implementazione condiziona il modo in cui
possiamo usufruire di queste caratteristiche.
Uno stato è diviso in tre porzioni di codice:
- La prima (entry state) porzione viene eseguita solo all’ingresso nello stato.
- La seconda (potremmo chiamarla loop state o run state), viene eseguita ciclicamente e solitamente è presente una condizione di uscita dallo stato.
- La terza (exit state) si comporta in modo simile a entry state, ma il codice viene eseguito dopo che run state ha selezionato il prossimo stato. In altre parole, si esegue del codice, quando si esce dallo stato, indipendentemente da quale sia lo stato successivo, anche questa porzione viene eseguito una sola volta.
Ora è più chiaro perché On Entry State e On Exit State sono equivalenti di Entry state ed Exit state. Vediamo come appare lo pseudo codice dello stato START con entry ed exit state.
-> START
{
ON_ENTRY_STATE {
# codice eseguito solo all'ingresso dello stato
}
# Init run state
# Accetta comando Play code:168
# POLLING(attende pressione Play)
PLAY IS PRESSED?: GOTO S_INOPENING;
# End run state
ON_EXIT_STATE {
# codice eseguito all'uscita dello stato.
}
}
Lo pseudo codice pecca di precisione in GOTO INOPENING
, che lascia intendere un salto allo stato INOPENING
,
senza passare per ON_EXIT_STATE{}, cosa non corretta poiché deve prima
eseguire ON_EXIT_STATE e abbandonare lo stato corrente per lo stato INOPENING
. Al posto di GOTO
avrei potuto usare FUTURE_STATE(
che adesso sembra più adatto, ma anche INOPENING
)SET_NEXT_STATE()
sarebbe ancora meglio, ma l’influenza dai linguaggi di programmazione ha avuto la meglio per adesso.
La codifica richiede però delle variabili di supporto:
g_oldState
è una variabile fotocopia dig_state
g_onEntryState
è una variabile globale di tipo bool che diventa true per un solo ciclo di loop.
Vediamo prima il codice del case S_START
con entry ed exit state:
case S_START:
/* ENTRY STATE */
if (g_onEntryState) {
Serial.println(F("Enter to S_START"));
}
/* RUN STATE */
if (command == IRCMD_PLAY) {
g_state = S_INOPENING;
}
/* EXIT STATE */
if (g_state != g_oldState) {
Serial.println(F("Exit from S_START"));
}
break;
Due sole if (…) sono necessarie per ogni stato per ottenere ciò che vogliamo. Mentre nel loop dobbiamo aggiungere del codice per fare si che g_onEntryState sia vera per un solo ciclo di loop. Vediamo il codice da inserire all’inizio della funzione loop:
if (g_oldState != g_state) {
g_oldState = g_state;
g_onEntryState = true;
} else {
g_onEntryState = false;
}
Semplice semplice, quasi non serve commentare, ma qualcosa la scrivo in merito. Il meccanismo delle due variabili (g_state
e g_oldState
)
non in sincro è ampiamente usato da sempre e lo uso nel modo classico;
se le due variabili sono differenti, le rendo uguali e mi appunto ciò
nella variabile g_onEntryState
che diventa true. Al prossimo ciclo g_oldState
e g_state
sono uguali per cui g_onEntryState
ritorna a false.
Devo solo fare attenzione a non modificare la variabile g_oldState
all’interno di uno stato. All’interno dello stato uso lo stesso
meccanismo delle variabili non in sincro per implementare lo exit state.
Ma devo anche garantire che le due variabili siano diverse all’avvio
dell’applicazione per cui la variabile globale g_oldState
e dichiarata e inizializzata così:
uint8_t g_oldState = 255;
In verità devo fare attenzione a non modificare anche altre variabili che è lecito potere leggere ma non scrivere. I problemi seri si presentano quando il numero di variabili che non devono essere scritte è tale da portarmi in errore. Dobbiamo considerare che oltre alle variabili che gestiscono il funzionamento degli stati ci sono anche le variabili usate dall’applicazione. Più il numero di variabili globali cresce più cresce la probabilità di commettere errori. Potremmo essere tentati dal dare un nome particolare alle variabili che non devono essere scritte, ma il linguaggio ci fornisce strumenti più flessibili e potenti, perché non usarli? Ma di questo avrò forse modo in futuro di scrivere qualcosa in merito, per adesso ci dobbiamo accontentare dello switch case e delle variabili globali.
Adesso è arrivato il momento di osservare il codice sorgente completo di entry ed exit state per farci una idea complessiva della complessità attuale.
/*
parking-access-s1 is demo application of parking access.
Copyright (C) 2022 Maurilio Pizzurro
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 1, or (at your option)
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this library. If not, see <http://www.gnu.org/licenses/>.
*/
/*
Pulsante verde per simulare la barriera IR
Pulsante play sul telecomando per alzare la barra.
*/
#include <IRremote.h>
#include <LiquidCrystal.h>
#include <Servo.h>
#define PIN_RECEIVER 2 // Signal Pin of IR receiver
IRrecv receiver(PIN_RECEIVER);
Servo RCservo;
LiquidCrystal lcd(12, 11, 10, 9, 8, 7);
/* SERVO_UP servo in posizione verticale */
#define SERVO_UP 180
/* SERVO_DOWN servo in posizione orizzontale */
#define SERVO_DOWN 90
/* S_XXX stati della macchina a stati g_state */
#define S_START 0
#define S_INOPENING 1
#define S_ISUP 2
#define S_INCLOSING 3
#define IRCMD_PLAY 168
// g_state è la variabile di stato
uint8_t g_state = S_START;
uint8_t g_oldState = 255;
uint8_t g_servoPos = SERVO_DOWN;
uint32_t g_saveMillis = 0;
bool g_onEntryState = false;
/* PIN_IR_BARRIER contatto pulito relay IR RX */
const byte PIN_IR_BARRIER = 4;
void setup()
{
Serial.begin(115200);
pinMode(PIN_IR_BARRIER, INPUT_PULLUP);
receiver.enableIRIn(); // Start the receiver
lcd.begin(16, 2);
Serial.print("Press play or\nyellow button\n");
RCservo.attach(5);
RCservo.write(g_servoPos);
}
void loop() {
uint8_t command = 0;
if (receiver.decode()) {
command = receiver.decodedIRData.command;
receiver.resume(); // Receive the next value
}
/* Gestione automatica degli entry state */
if (g_oldState != g_state) {
g_oldState = g_state;
g_onEntryState = true;
} else {
g_onEntryState = false;
}
switch (g_state) {
case S_START:
/* ENTRY STATE */
if (g_onEntryState) {
Serial.println(F("Enter to S_START"));
}
/* RUN STATE */
if (command == IRCMD_PLAY) {
g_state = S_INOPENING;
}
/* EXIT STATE */
if (g_state != g_oldState) {
Serial.println(F("Exit from S_START"));
}
break;
case S_INOPENING:
/* ENTRY STATE */
if (g_onEntryState) {
Serial.println(F("Enter to S_INOPENING"));
}
/* RUN STATE */
if (millis() - g_saveMillis > 20) {
g_saveMillis = millis();
if (g_servoPos <= SERVO_UP) {
g_servoPos++;
RCservo.write(g_servoPos);
}
if (g_servoPos == SERVO_UP) {
/* CHANGE STATE */
g_state = S_ISUP;
g_saveMillis = millis();
}
}
/* EXIT STATE */
if (g_state != g_oldState) {
Serial.println(F("Exit from S_INOPENING"));
}
break;
case S_ISUP:
/* ENTRY STATE */
if (g_onEntryState) {
Serial.println(F("Enter to S_ISUP"));
}
/* RUN STATE */
if (millis() - g_saveMillis > 2000) {
/* CHANGE STATE */
g_state = S_INCLOSING;
}
/* CHECK IR BARRIER */
if (digitalRead(PIN_IR_BARRIER) == LOW) {
/* RESTART TIMER */
g_saveMillis = millis();
}
/* EXIT STATE */
if (g_state != g_oldState) {
Serial.println(F("Exit from S_ISUP"));
}
break;
case S_INCLOSING:
/* ENTRY STATE */
if (g_onEntryState) {
Serial.println(F("Enter to S_INCLOSING"));
}
/* RUN STATE */
if (millis() - g_saveMillis > 35) {
g_saveMillis = millis();
if (g_servoPos >= SERVO_DOWN) {
g_servoPos--;
RCservo.write(g_servoPos);
}
if (g_servoPos == SERVO_DOWN) {
/* CHANGE STATE */
g_state = S_START;
}
}
/* CHECK IR BARRIER */
if (digitalRead(PIN_IR_BARRIER) == LOW) {
/* CHANGE STATE */
g_state = S_INOPENING;
}
/* EXIT STATE */
if (g_state != g_oldState) {
Serial.println(F("Exit from S_INCLOSING"));
}
break;
} // end switch
} // end loop()
Per fare compiere qualcosa agli Entry e Exit state ci ho messo la stampa su seriale, così stamperà lo stato in cui entra e quello che da cui esce. In questo modo non intaso la seriale di stampe tutte identiche ma soltanto due stampe per stato, una all’ingresso e l’altra in uscita.
Tiriamo le somme, così si dice, abbiamo 5 variabili globali, 2 delle quali gestiscono gli entry state, cioè g_oldState
e g_onEntryState
e sono critiche.
Il programma conta 168 righe inclusi gli spazi. Il codice all’interno di un case non supera la dimensione di una schermata.
Abbiamo ancora alcune macro che potremmo pure trasformare in enumerati tramite la parola chiave enum
.
Sebbene la situazione sembri sotto controllo, ci vuole poco perché diventi quasi ingestibile. Il problema è che facciamo tante cose in una porzione di codice delimitata dalla funzione loop. Anche se ci sono solo 2 variabili critiche, sono li a portata di mano del distratto di turno, ma noi vogliamo assaggiare cosa significa darsi una martellata sul dito per poi imparare a piantare chiodi, quindi farciremo il loop di codice, nell’attesa che arrivi il momento, in cui faremo fatica a seguire il codice e prima o poi, questo momento arriverà.
Farciamo il loop
Cosa ancora gli possiamo fare compiere? Vediamo proviamo a fare una lista di funzionalità mancanti più o meno utili:
- Visualizza sul display la posizione del servo RC anche e specie quando si muove.
- Lampeggiante a due led, uno rosso e uno giallo.
- Visualizzare sul display il conto alla rovescia del timeout di 2 secondi, cosi da visualizzare: 1999, 1998. 1997 ecc, fino a zero. Così possiamo vedere ancora meglio cosa accade se premiamo il pulsante verde.
- Conteggio delle aperture e chiusure della barra. Ma a cosa ci serve? a nulla, anche se nella realtà serve per fare la manutenzione programmata, di cui non abbiamo necessità, lo implementiamo lo stesso per farcire il loop.
- Aggiunta di un pulsante giallo che simula il contatto chiave; se il telecomando ha le pile scariche posso entrare con la chiave e mi pare il minimo.
- Durante l’esecuzione dello stato S_START voglio che dopo una inattività di 50 secondi si passi allo stato IDLE, mettendo la MCU a nanna (in sleep).
- C’è un display, usiamolo come si vede fare spesso, per i 50 secondi prima di andare a nanna, gli facciamo fare una animazione scorrevole.
Mi fermo qui solo perché già mi sembra abbastanza e c’è il rischio che se ci rifletto altri 10 minuti tiro fuori altri 5 o 6 punti.
Visualizza la posizione del servo
Il
punto (1) si ottiene con poca fatica, senza aggiungere alcuna variabile
globale. Ci serve solo una funzione di supporto di nome: void lcdPrintPos(uint8_t pos)
. Potremmo fare a meno dell’argomento pos
,
poiché la variabile g_servoPos è globale, ma non è mai cosa buona
creare funzioni che usano variabili globali, ma il motivo principale e
che osservando il case
in cui avviene la chiamata, si nota subito che gli viene passato come argomento il valore di g_servoPos
. Se per ipotesi g_servoPos
non esistesse e per ottenere la posizione del servo dovessi chiamare il metodo getPos()
di un oggetto ctrlBar
, potrei continuare ad usare la funzione chiamandola così: lcdPrintPos(ctrlBar.getPos())
. Quindi ci sono ottimi motivi per lasciare li al suo posto l’argomento pos
. Bene vediamo il codice di questa funzione di supporto:
void lcdPrintPos(uint8_t pos) {
char posBuff[4];
posBuff[ 4 - 1 ] = '\0';
sprintf(posBuff, "%#3u", pos);
lcd.setCursor(1, 1);
lcd.print(posBuff);
}
Semplice,
breve e usa solo variabili locali, cosa si può volere di più? La
funzione si limita a visualizzare su lcd il valore di una variabile
grande 1 byte che al massimo può assumere valore 255 decimale, ed è
composta da 3 caratteri al massimo. Vista la genericità e la
indipendenza di questa funzione, potremmo pure dargli un nome più
generico e aggiungere gli argomenti row
e col
così da usarli in più occasioni. Lasciamo le cose come stanno al
momento, in futuro se avessimo necessità, apporteremo le modifiche per
renderla generica.
Non resta che inserire la chiamata della funzione nei due case
che modificano la posizione del servo, e questi sono: S_INOPENING
e S_INCLOSING
. Qui a seguire solo la porzione RUN STATE
di S_INOPENING
:
/* RUN STATE */
if (millis() - g_saveMillis > 20) {
g_saveMillis = millis();
if (g_servoPos <= SERVO_UP) {
g_servoPos++;
RCservo.write(g_servoPos);
lcdPrintPos(g_servoPos); // La chiamata in questione.
}
if (g_servoPos == SERVO_UP) {
/* CHANGE STATE */
g_state = S_ISUP;
g_saveMillis = millis();
}
}
Qui a seguire sempre solo la porzione RUN STATE
di S_INCLOSING
.
/* RUN STATE */
if (millis() - g_saveMillis > 35) {
g_saveMillis = millis();
if (g_servoPos >= SERVO_DOWN) {
g_servoPos--;
RCservo.write(g_servoPos);
lcdPrintPos(g_servoPos);
}
if (g_servoPos == SERVO_DOWN) {
/* CHANGE STATE */
g_state = S_START;
}
}
/* CHECK IR BARRIER */
if (digitalRead(PIN_IR_BARRIER) == LOW) {
/* CHANGE STATE */
g_state = S_INOPENING;
}
Ci manca soltanto chiamare la stessa funzione nel setup(). Il risultato finale sul display sarà qualcosa di simile a quello in Figura 1.
Bene, questa è fatta e non me la sono sentita di farcire i 2 case
con il codice della funzione per poi mostrare che avremmo fatto meglio a creare una funzione di supporto.
Lampeggiante a due led
Il segnalatore luminoso di movimento o semplicemente lampeggiante è composto da 2 led, uno rosso e uno giallo. Servono 2 pin liberi a scelta, io ho scelto il pin 13 (a cui è già connesso il led contrassegnato con la sigla L sulla board. L’altro pin vicino è A0, ma può benissimo essere impiegato in digitale. Servono due resistori da 330 ohm (470÷1000 ohm) ognuno in serie tra led e pin. Lo schema di collegamento è mostrato in Figura 1, dove compare anche il pulsante giallo che simula il contatto sotto chiave.
Aggiungiamo alla funzione setup() le pinMode() dei due pin come output:
pinMode(LED_BUILTIN, OUTPUT);
pinMode(A0, OUTPUT);
Ora ci serve una funzione di supporto la cui chiamata verrà inserita nella funzione loop(), vediamola subito questa funzione:
void flashingLight(bool tf) {
#define FL_TIMING 200
static uint32_t timer = 0;
static uint8_t timing = 0;
if (!tf) {
digitalWrite(LED_BUILTIN, LOW);
digitalWrite(A0, LOW);
timer = 0;
timing = 0;
return;
}
if (millis() - timer > timing) {
bool yelIsOn = !digitalRead(A0);
timer = millis();
timing = FL_TIMING;
digitalWrite(LED_BUILTIN, !yelIsOn);
digitalWrite(A0, yelIsOn);
}
}
Da commentare ci sono le due variabili statiche timer
e timing
che non è necessario siano globali. Se la chiamata equivale a: flashingLight(0)
la if (!tf) {
verrà eseguita e questa spegne entrambe i led, azzera le due variabili e
restituisce il controllo al chiamante. Se al posto dell’argomento 0
c’è un valore differente da 0
(1, 2, 3 ecc) viene eseguita la seconda if
temporizzata con millis(), dove però timer
e timing
hanno valore 0, per cui si entra subito e vediamo che alle due variabli
in questione gli vengono assegnati dei valori. Inoltre qui dobbiamo
accendere in modo alternato i led, cioè ad ogni chiamata della funzione,
il led che era acceso si spegne e quello che era spento si accende.
Viene da chiedersi; ma che argomento gli dobbiamo passare alla funzione? Guarda caso, combinazione vuole, che la variabile g_state
assuma il valore zero, solo quando si sta eseguendo il case S_START
, proprio quando serve il lampeggiante acceso, la variabile g_state
ha valori diversi da zero, sembra fatto di proposito. Quindi la
chiamata a funzione viene inserita all’inizio della funzione loop(),
mostrata qui di seguito parzialmente:
void loop() {
flashingLight(g_state);
uint8_t command = 0;
if (receiver.decode()) {
Non
sembra vero, c’è la siamo cavati con poco codice, una sola riga
inserita nella funzione loop(). Non dimentichiamolo, si ottiene ciò
grazie alle funzione di supporto flashingLight()
. Nonostante mi stia impegnando nell’intento di farcire il loop di codice, qualcosa me lo impedisce, ma non demordo.
Visualizzare il timeout su LCD
Anche questo punto può essere risolto implementando una funzione di supporto. Vediamola questa funzione:
void lcdPrintTimeout(uint16_t t) {
char timeBuff[5];
timeBuff[ 5 - 1 ] = '\0';
sprintf(timeBuff, "%#4u", t);
lcd.setCursor(11, 1);
lcd.print(timeBuff);
}
La chiamata avviene solo nel case S_ISUP
quindi potremmo pure copiare il codice della funzione dentro al case. Tuttavia dentro ai case
non è permesso dichiarare variabili locali, se lo si desidera si può
aggirare questa limitazione aggiungendo un blocco di codice { }, il case
apparirebbe così:
case S_ISUP:
{
char timeBuff[5];
timeBuff[ 5 - 1 ] = '\0';
// ecc
}
In
questo caso lo sconsiglio e continuo a preferire la funzione di
supporto. Bene vediamo la chiamata, di seguito viene mostrato solo il
run state del case S_ISUP
:
/* RUN STATE */
if (millis() - g_saveMillis > 2000) {
/* CHANGE STATE */
g_state = S_INCLOSING;
lcdPrintTimeout(0);
} else {
uint32_t dt = millis() - g_saveMillis;
lcdPrintTimeout(2000 - dt); // ecco la chiamata a funzione
}
/* CHECK IR BARRIER */
if (digitalRead(PIN_IR_BARRIER) == LOW) {
/* RESTART TIMER */
g_saveMillis = millis();
}
Grazie al blocco di codice else {} posso creare la variabile locale dt
che mi serve per chiamare la funzione lcdPrintTimeout()
. Poco prima di } else {
c’è la chiamata a lcdPrintTimeout(0)
per aggirare un comportamento non desiderato evidenziato durante i
test. Il problema è che alle volte il display mostra un timeout residuo
di 1 millesimi di secondo ed esce dal case S_ISUP
, mentre
io voglio che mostri 0 millesimi e poi può uscire dal case. Questo
problema dovevo aspettarmelo, ma me lo sono tolto dalla testa, per
fortuna, ho la funzione di supporto su cui posso contare.
Mentre il conto alla rovescia (partendo da 2000) viene visualizzato sul display, premo il pulsante verde, e sul display si vede ripartire, da 2000 il conto alla rovescio. Era ciò che volevo ottenere e quindi anche questo punto è implementato.
Il punto 4
Questo punto sarebbe semplice da implementare su una scheda vera, ma con wokwi la eeprom non mantiene le informazioni tra una simulazione e l’altra, per cui questo punto già poco utile non sarà implementato.
Apertura con chiave
Comandare
il sollevamento della barra da un contatto chiave è facile, basta
aggiungere un pulsante e collegarlo ad un pin. In Figura 1 si vede il
pulsante giallo collegato al pin D6. Il codice è davvero semplice, non
dimentichiamo la pinMode()
nel setup()
. Il codice parziale del loop()
è sufficiente per mostrare come lavora:
void loop() {
flashingLight(g_state);
uint8_t command = 0;
if (receiver.decode()) {
command = receiver.decodedIRData.command;
receiver.resume(); // Receive the next value
} else if (digitalRead(PIN_KEY) == LOW) {
command = IRCMD_PLAY;
}
In breve, la variabile command
può assumere un valore proveniente da due sorgenti. La prima sorgente è
il ricevitore IR. La seconda sorgente è il pulsante giallo.
Il punto 6
Non
si può avere tutto, sembra che il simulatore wokwi non implementi il
risparmio energetico della MCU, per cui non posso mettere la MCU a nanna
(sleep).
Ma non è un grosso problema, l'applicazione non è reale, se
lo fosse mi dovrei preoccupare di togliere l'alimentazione al servo
motore in modo da ridurre il consumo di energia in modo considerevole.
Se questa applicazione non reale, dovesse trovare applicazione pratica
in un plastico e volessimo risparmiare energia, potremmo quanto meno
usare il metodo detach(), ma prima di muovere il servo dovremmo inserire
attach(pin).
Conclusione
Concludo
qui saltando il settimo punto perché ancora non implementato. Devo dire
che, non avere potuto implementare i punti 4 e 6 mi ha destabilizzato e
ora non so come sfruttare meglio la presenza del display. Si vedrà in
seguito quando fare comparire la scritta scorrevole e il suo contenuto.
Di sicuro lo stato S_START
è quello in cui si permane per lungo tempo a patto di non premere nulla, per cui S_START
è
il cadidato ideale in cui fare comparire delle scritte scorrevoli.
Congelare il codice a questo stadio dello sviluppo è pure utile, poiché
si hanno le funzionalità principali con una implementazione ancora
chiara e da questa implementazione possiamo derivarne altre, ad esempio
mettendo il tutto dentro una o più classi C++. Per coloro i quali
fossero interessati alle scritte scorrevoli lascio il link
ad un articolo che affronta il problema. L'applicazione qui descritta
come sempre la potete sperimentare su wokwi seguendo questo link.
Quest'opera è distribuita con Licenza Creative Commons Attribuzione 4.0 Internazionale
Nessun commento:
Posta un commento