venerdì 25 marzo 2022

Applicazione accesso a parcheggio - Entry ed Exit state

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(INOPENING) che adesso sembra più adatto, ma anche 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 di g_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:

  1. Visualizza sul display la posizione del servo RC anche e specie quando si muove.
  2. Lampeggiante a due led, uno rosso e uno giallo.
  3. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 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.

Figura 1: Screenshot del progetto su wokwi

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.

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione 4.0 Internazionale

giovedì 24 marzo 2022

Applicazione accesso a parcheggio

Una "semplice” automazione, bugia, non è per niente semplice scrivere una applicazione come questa. 

Le difficoltà da superare sono molteplici:

  1. Il programma deve comportarsi come desiderato.
  2. Il programma deve essere manutenibile ed estendibile.
  3. Il codice deve essere chiaro e comprensibile.

Questi punti non sono requisiti, ma punti cardine che ogni applicazione deve rispettare. Se il programma rispetta il punto (1), ma fallisce sul punto (2), si dovrà trovare il modo di apportare modifiche, per centrare il punto (2), senza compromettere il punto (1). In breve, qualunque sia il motivo che ti spinge a modificare il codice, devi essere consapevole, che nell’intento di rispettare il punto (2), puoi facilmente compromettere il punto (1). Stessa cosa invertendo (1) e (2).

I requisiti

Questa applicazione non ha alcun requisito, poiché non è reale e concreta. Non si ha l’obbligo (e responsabilità) di rispettare alcuna normativa di legge, nessuna certificazione è necessaria. Tutto ciò è una enorme semplificazione, nonostante ciò, sarà necessario investire del tempo, tempo che non ci manca certo, poiché faremo degli errori, ripareremo a questi e faremo tesoro di ciò che abbiamo imparato. Alla fine ci illuderemo di avere rispettato tutti i punti cardine.

L’hardware necessario

Alcuni componenti possono essere sostituiti, ad esempio il ricevitore IR e il telecomando possono essere sostituiti con un pulsante. La coppia fotocellule IR RX e TX, qui è sostituita con un pulsante verde. La fotocellula RX contiene un relay i cui contatti si chiudono se il fascio IR viene interrotto, in altre parole, se il sensore IR non riceve il fascio IR trasmesso da TX, i contatti del relay sono chiusi. Questo può accadere anche per un mancato allineamento ottico tra TX ed RX. I contatti del relay sono sostituiti dai contatti normalmente aperti del pulsante verde.

Screenshot del progetto sviluppato con il simulatore wokwi.
 

Elenco componenti

1 Arduino qualunque, la UNO, nano, MEGA.
1 Display LCD 2 o 4 righe.
1 RC Servo motore per simulare la barra.
1 Pulsante per simulare la coppia di fotocellule IR.
1 Pulsante PLAY o ricevitore IR più telecomando.

Descrizione dell’applicazione

Qualunque descrizione che impieghi Italiano, Inglese ecc, anche se è dovuta, non è sufficiente per esprimere tutti dettagli senza rischi di essere fraintesi. Per tale motivo, è bene fornire una descrizione impiegando un linguaggio alternativo, che si presti a descrivere in modo sintetico, anche i dettagli. Uno di questi linguaggi è detto pseudo linguaggio, purtroppo non esiste uno standard da seguire per esprimersi universalmente.

Descrizione in linguaggio corrente

Il telecomando verrà usato per azionare l’automazione, poiché è sufficiente un tasto (ad es. Play), il telecomando e il ricevitore IR, possono essere sostituiti con un semplice pulsante.

Il servo RC simula la barriera che consente o nega l’accesso al parcheggio. In questo caso, l’automazione consente l’accesso a chi è dotato di telecomando.

La barriera IR o coppia di fotocellula (TX, RX), qui viene sostituita dal pulsante verde. Il pulsante premuto equivale, ad interruzione della barriera e ciò indica il transito.

Premuto il tasto “Play” la barra si posiziona in verticale permettendo il transito di veicoli.

La barra resta in verticale 2 secondi, dopo torna lentamente in orizzontale. Durante i 2 secondi di attesa si può verificare l’interruzione della barriera IR (pulsante verde premuto), in questo caso il tempo di attesa si somma al tempo già trascorso; ad esempio 1.2s già trascorsi più 2 secondi dalla pressione del pulsante verde.

Tenere premuto il pulsante verde equivale a sostare tra le fotocellule, in questa condizione la barra rimarrà in verticale. La barra inizierà a scendere in orizzontale 2 secondi dopo avere rilasciato il pulsante verde.

La pressione sul pulsante durante la discesa della barra comporta inversione di rotazione e la barra si porta in verticale.

Pseudo linguaggio

La descrizione precedente pare già sufficientemente dettagliata, tuttavia non fornisce un contributo sufficiente a formulare un algoritmo, cosa che, prima o poi dovremo fare, per cui, usiamo un linguaggio che è una via di mezzo, tra la lingua di cui abbiamo maggiore padronanza e il linguaggio di programmazione di cui abbiamo maggiore padronanza.

Da quanto detto, nasce lo pseudo linguaggio, il quale dovrebbe essere totalmente estraneo nei confronti dei linguaggi di programmazione informatica. Tuttavia è difficile non esserene influenzati, per cui, istanze come: BEGIN, IF, GOTO, END, FOR ecc sono frequentemente usate.

Nel caso specifico ho preferito istanze come:

    GREEN BUTTON 
    {
        ISPRESSED?     
        NOPRESSED?
    }

Nota il punto di domanda ?, che possiamo leggere facilmente GREEN BUTTON IS PRESSED? 

La stessa istanza GREEN BUTTON può essere espansa in:

    GREEN BUTTON 
    {
        ISPRESSED?
        {
            # qui il codice da eseguire in caso il pulsante verde risulta premuto
        }     
        NOPRESSED?
        {
            # codice nel caso il pulsante verde non è stato premuto
        }
    }

Per inserire commenti ho usato il carattere cancelletto derivato dal linguaggio python. Qui a seguire, lo pseudo codice che descrive l’algoritmo della automazione in questione.

Pseudo codifica

# Pseudo codice automazione controllo barra accesso a parcheggio. 
-> START
{
    # Accetta comando Play code:168
    # POLLING(attende pressione Play) 
    PLAY IS PRESSED?: GOTO INOPENING;
} 
-> INOPENING
{
    ACT(RCSERVO, UP)
    IS SERVO UP?
    {
        YES: SAVETIME, GOTO ISUP;
        NOT:  ---  
    }
} 
-> ISUP
{
    GREEN_BUTTON
    {   
        ISPRESSED?: SAVETIME;
        NOTPRESSED: ---
    }
    IS_TIMEOUT?(2s)
    {
        GOTO INCLOSING;
    }
}
-> INCLOSING
{
    ACT(RCSERVO, DOWN)
    IS SERVO DOWN?
    {
        GOTO START;
    }
    GREEN_BUTTON
    {   
        ISPRESSED?: GOTO INOPENING;
        NOTPRESSED: ---
    }
}

Pseudo codifica di START

Dato che non esiste uno standard sullo pseudo linguaggio possiamo scrivere quello che vogliamo, ma attenzione ad essere coerenti e rispettare la sintassi.

La descrizione dell’algoritmo in questione appena vista è molto distante dalla codifica reale ed è anche superficiale. Se lo si desidera, si possono aggiungere commenti per indicare il grado di superficialità, ad esempio diamo per scontato che la descrizione precedente sia buona, ma ne vogliamo creare un altra meno superficiale punto per punto, iniziando da START.

    START # is a state of the finite state machine g_state
    {
        IF (IR.COMMAND == PLAY)
            # CHANGE STATE  
            g_state = INOPENING 

    }

Da questo pseudo codice si intuisce che, START è uno degli stati della macchina a stati finiti (FSM) g_state. Anche INOPENING è uno stato delle stessa FSM. In questo stato, la cpu esegue ripetutamente il codice che verifica se é stato premuto il tasto PLAY sul telecomando, infatti quando il ricevitore IR, riceve il comando COMMAND e questo è uguale al codice PLAY, g_state assume lo stato INOPENING. Già questa descrizione è più vicina alla codifica finale e ci fornisce maggiori informazioni utili per la codifica nel linguaggio scelto.

La codifica in C

La codifica in C la possiamo osservare di seguito:

    case S_START:
        if (command == IRCMD_PLAY) {
            /* CHANGE STATE */
            g_state = S_INOPENING;     
        }      
        break;

S_START, IRCMD_PLAY e S_INOPENING sono macro del preprocessore, mentre g_state è una variabile globale di tipo uint8_t, qui a seguire il codice:

    
    #define S_START        0
    #define S_INOPENING    1
    #define IRCMD_PLAY     168    
    uint8_t   g_state =    S_START;

La variabile command è dichiarata locale nella funzione loop, il suo valore iniziale è zero. Se il ricevitore IR riceve un comando, questo viene assegnato alla variabile command.

Codifica C parziale

Arrivati a questo punto può tornare utile osservare il codice non completo (ma funzionante) dello sketch:

    #include <IRremote.h>
    
    #define PIN_RECEIVER 2   // Signal Pin of IR receiver
    IRrecv receiver(PIN_RECEIVER);

    #define S_START     0
    #define S_INOPENING 1
    #define IRCMD_PLAY  168
    uint8_t   g_state = S_START;

    void setup() {
        Serial.begin(115200);
        receiver.enableIRIn(); // Start the receiver
    }

    void loop() {
        uint8_t command = 0;
        if (receiver.decode()) {
            command = receiver.decodedIRData.command;
            receiver.resume();  // Receive the next value
        }

        switch (g_state) {
            
            case S_START:
                if (command == IRCMD_PLAY) {
                    /* CHANGE STATE */
                    g_state = S_INOPENING;     
                }      
                break;

            case S_INOPENING:
                // Not implemented 
                Serial.print("INOPENING");
                delay(1000);
                
                /* CHANGE STATE */
                g_state = S_START;
                break;
        }
    }

Il case S_INOPENING non è implementato, al suo posto abbiamo inserito la stampa su seriale di un testo, un delay e infine il cambio di stato che ci riporta al case S_START. Se il programma si comporta come desiderato si può passare ad implementare il prossimo stato.

Lo stato INOPENING

Il case S_INOPENING ha il compito di azionare il motore del servo RC fino a posizionarlo a 180 gradi. 

Immaginiamo di usare, al posto del servo RC, un motore con encoder accoppiato sull’asse. Servirà controllare velocità di rotazione e posizione. Potere controllare la velocità di rotazione del motore in relazione alla posizione in cui si trova la barra, ci permette di attuare la movimentazione in modo dolce è progressivo, riducendo gli stress elettrici e meccanici. Al contempo dobbiamo anche garantire una certa rapidità di apertura e chiusura della barra. Avremo quindi una velocità di rotazione bassa iniziale, che incrementa in relazione alla posizione. Arriveremo così ad avere una velocità di rotazione massima di picco, che permane fino al punto in cui la velocità, si riduce progressivamente, fino ad arrestarsi nella posizione desiderata.

Per fortuna noi usiamo un servo RC, la cui velocità di posizionamento è fissa, diciamo per fortuna, poiché provate a fissare sul braccio del servo, un asta lunga 30 cm, l’arresto alla posizione desiderata avverrà in brevissimo tempo e l’asta oscillerà, stessa cosa nel verso opposto. Potremo fare finta di nulla e risolvere con un semplice RCservo.write(180), invece vogliamo incrementa la posizione di un grado ogni 35ms, ciò vuole dire, che per compiere una rotazione di 90°, ci vorranno: 90° x 35ms = 3150ms (~3s). Mentre la discesa della barra sarà ancora più lenta: 90° x 50ms = 4500 (4.5s). In ogni caso sarà possibile impostare una diversa velocità.

La codifica in C

Lo stato S_INOPENING necessità di due variabili globali: g_saveMillis e g_servoPos e due macro: SERVO_UP e S_ISUP.

Grazie alla prima if (millis()... il codice tra le graffe, viene eseguito ogni 35ms. g_servoPos vale 90 e SERVO_UP vale 180, quindi g_servoPos è minore di SERVO_UP, per cui incrementiamo di 1 g_servoPos, che adesso vale 91, aggiorniamo la posizione del servo con RCservo.write(g_servoPos) e così via, fino a che g_servoPos vale 180 e siamo pronti per il prossimo stato, ma prima salviamo il tempo in g_saveMillis. Al prossimo ciclo di loop, verrà selezionato il case S_ISUP, non ancora implementato.

        case S_INOPENING:
            
            if (millis() - g_saveMillis > 35) {
                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();
                }
                
            }
            break;

Quando ci occuperemo di implementare il case S_INCLOSING vedremo che è simile a S_INOPENING appena descritto. 

Avrete notato che aggiungo il commento CHANGE STATE, nella riga che precede l’assegnazione, di un nuovo stato alla variabile g_state. Potrà sembrarvi superfluo, ma non lo è, poiché i punti di uscita dallo stato corrente, ci servono per seguire il flusso di esecuzione. Inoltre, se uno o più case avessero più uscite diverse tra loro, il codice probabilmente sarebbe composto da molte più righe e sarebbe ancora meno semplice seguire il flusso di esecuzione e se ci lasciassimo prendere la mano introducendo 20, 30, 50 righe all’interno di un case, dovremmo sentire una vocina che ci dice; non farlo, poi non ci capisci più nulla, prova invece a snellire il case creando delle funzioni di supporto. Ma questo argomento lo affronteremo più avanti, proprio facendo quello che non dovremmo.

Lo stato ISUP

Questo stato si occupa di monitorare gli eventi. Due sono gli eventi di cui soltanto uno determina lo stato di uscita:

  1. È scaduto il timeout di 2 secondi.
  2. Il pulsante verde è stato premuto.

I due secondi di timeout sono conteggiati a partire dall’istante in cui la barra è in posizione verticale (180). Durante questo intervallo di tempo, la pressione del pulsante verde, ricarica il timeout di 2 secondi. Sarà pertanto possibile permanere in questo stato un tempo infinito, mantenendo premuto il tasto verde. Questo comportamento non è evitabile, tuttavia, il permanere in questo stato per lungo tempo, potrebbe essere annotato in una tabella degli errori, consultabile attraverso degli strumenti software di diagnosi impiegati dal tecnico della manutenzione. Poiché questa non è una applicazione reale, non ci poniamo il problema e non gestiremo alcuna tabella degli errori.

La codifica in C

La codifica in C, come si può osservare è molto semplice ed esplicita, tanto che ogni ulteriore commento è superfluo. Risulta evidente che il prossimo stato da implementare è S_INCLOSING.

        case S_ISUP:
            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();
            }
            break;

Lo stato INCLOSING

Come detto prima, lo stato INCLOSING è simile allo stato INOPENING, con la differenza che g_servoPos deve essere decrementata di 1. In aggiunta dobbiamo prevedere che, durante il movimento della barra, un veicolo o persona interrompa il fascio IR, simulato dalla pressione del pulsante verde. Nel caso ciò si verifichi, cediamo il controllo allo stato INOPENING, invertendo così il movimento della barra.

Mentre se il pulsante verde non viene premuto, la barra arriverà alla posizione verticale (90°) e possiamo cedere il controllo allo stato START.

La codifica in C

La codifica in C è molto simile al case INOPENING, come si vede, al posto di g_servoPos++ c’è g_servoPos--. Ovviamente anche la condizione va modificata in modo che, g_servoPos non possa assumere valore inferiore a SERVO_DOWN.

Il controllo CHECK IR BARRIER è identico a quello precedente, con la differenza che, adesso cediamo il controllo al case S_INOPENING.

        case S_INCLOSING:
            if (millis() - g_saveMillis > 50) {
                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;
            }
            break;

Conclusione

Questa implementazione non è l’unica possibile e non vuole essere la migliore implementazione in assoluto, invece vuole essere la più adatta per fini didattici, e lo è, poiché mostra in modo esplicito e sintetico tutti i meccanismi. Possiamo dire di avere rispettato tutti i punti cardine, o almeno ci siamo sforzati di rispettarli con discreto successo, complice il fatto che l’applicazione è semplice. Da questa implementazione possiamo derivarne altre, magari nascondendo i meccanismi dentro le classi C++, ma non è questa la strada che prenderò.

L’implementazione di una macchina a stati finiti tramite lo switch case si dimostra essere valida almeno fintantoché gli stati si possono contare sulle dita di una mano. Volendo è possibile semplificare lo switch case impiegando delle funzioni di supporto, invece lo complicherò al fine di verificare e constatare quando abbiamo raggiunto il limite. L’implementazione della macchina a stati attuale è priva di entry state ed exit state, vedremo come implementare gli entry state e quanto sono utili.

La attuale applicazione è mancante di alcune caratteristiche, ad esempio manca il segnalatore luminoso di movimento, detto lampeggiante, potremmo pure aggiungere la segnalazione sonora di movimento. Sicuramente aggiungerò un pulsante per simulare un contatto chiave con cui sollevare la barra.

Il display è stato usato marginalmente vedremo come usarlo meglio implementando gli entry state.

Codice sorgente completo

Per completezza allego a seguire il codice completo che potete provare online su wokwi.

/*
 parking access is demo application born to show a state 
    machine implementation.
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/>.
*/
/*
Parking access application
Pulsante verde per simulare la barriera IR
*/

#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

/* PIN_IR_BARRIER contatto pulito relay IR RX */
const byte PIN_IR_BARRIER = 4;

// g_state è la variabile di stato
uint8_t g_state = 0;
uint32_t g_saveMillis = 0;
uint8_t g_servoPos = SERVO_DOWN;

/* 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

void setup()
{
Serial.begin(115200);
pinMode(PIN_IR_BARRIER, INPUT_PULLUP);
receiver.enableIRIn(); // Start the receiver

lcd.begin(16, 2);

lcd.print("Press play");
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
}

switch (g_state) {
case S_START:
if (command == IRCMD_PLAY) {
/* CHANGE STATE */
g_state = S_INOPENING;
}
break;

case S_INOPENING:
if (millis() - g_saveMillis > 35) {
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();
}
}
break;

case S_ISUP:
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();
}
break;

case S_INCLOSING:
if (millis() - g_saveMillis > 50) {
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;
}
break;
} // end switch
} // end loop()

Applicazione accesso a parcheggio - Entry ed Exit state

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione 4.0 Internazionale