…e molti molti altri…
Un sistema a stati è qualsiasi cosa (sia hardware che software) che in ogni momento del suo funzionamento si trova in uno stato preciso tra un insieme di possibili stati. Ogni stato rappresenta “la situazione attuale”. Ad esempio un cancello automatico si può trovare sicuramente in almeno una di queste situazioni: “chiuso”, “in apertura”, “aperto”, “in chiusura”.
In ogni stato il sistema può reagire con specifiche azioni al verificarsi di determinati eventi. Per un cancello alcuni eventi possono essere: “pressione pulsante apertura”, “raggiunto fine corsa in apertura”, “urtato ostacolo”, “fotocellula oscurata”, “tempo scaduto” ecc.
Le azioni in risposta agli eventi rappresentano il comportamento del sistema, ad esempio: “avvia motore”, “accendi lampeggiante” ecc. In seguito ad un evento si può anche cambiare di stato: appena un cancello termina di aprirsi passa dallo stato “in apertura” allo stato “aperto”.
Dal punto di vista tecnico l’informazione sullo stato è semplicemente un valore (del tutto convenzionale) contenuto in una memoria (di qualsiasi tipo) che rappresenta la conoscenza che il sistema ha della situazione attuale. Con le informazioni di stato memorizzate, combinate con le letture degli ingressi, un sistema può comportarsi in modo “intelligente” di fronte ad una serie di eventi che avvengono con un certo ordine temporale. Quindi un distributore di bibite fornirà il prodotto solo dopo una sequenza ben precisa di operazioni, oppure una luce verrà accesa solo alla ricezione di una precisa sequenza di byte da una porta seriale.
In pratica ogni “macchina” in grado di interagire con il mondo reale, che riconosca o produca sequenze di eventi ordinate nel tempo in base a una memoria interna, è un sistema a stati (il sistema a stati più complesso esistente è sicuramente il nostro cervello).
Dal punto di vista generale, un programma a stati si può pensare
composto nel seguente modo:
La codifica è molto più semplice di quello che si può pensare. La funzione loop può richiamare ciclicamente i vari processi, anche centinaia o migliaia di volte al secondo, in modo da dare la sensazione che vengano eseguiti in parallelo contemporaneamente:
void loop() { processo1(); processo2(); processo3(); }
Ogni processo può essere descritto con uno switch controllato da una propria variabile di stato. Questa variabile deve mantenere il valore tra una chiamata e l’altra della funzione, perciò o si dichiara globale fuori da ogni funzione (ma per ogni processo la variabile dovrebbe avere un nome diverso), o si dichiara static all’interno della funzione (che è la scelta migliore, perché ogni processo ha la sua variabile “personale” di nome ‘stato’). Ovviamente la variabile va inizializzata allo stato che si vuole sia attivo all’avvio.
void processo1() { static byte stato = 0; switch(stato) { case 0: break; case 1: break; case 2: break; } }
A questo punto all’interno di ogni case basta scrivere il frammento di codice da eseguire nello stato corrispondente, che deve solo:
L’ esecuzione è quindi velocissima. Ad ogni “giro di loop” per ogni processo viene eseguito solo lo stato attivo. Lo stato si limita a verificare se qualche condizione risulta vera, con una logica:
rilevazione evento → azione → cambio stato
Per chi ha scritto solo programmi “convenzionali” monotask, composti da sequenze di istruzioni da eseguire dall’inizio alla fine in un colpo solo (magari contenenti ritardi prodotti con la funzione ‘delay’), probabilmente la parte più difficile da comprendere è proprio come può essere portata avanti un’elaborazione (anche molto lenta) scomposta in più passaggi ciclici singolarmente molto veloci e senza ritardi di alcun genere.
La cosa fondamentale da focalizzare, è che in un programma con questa struttura il loop principale deve sempre chiamare tutti i processi molto velocemente, e ogni ritardo o attesa in un processo è creato permanendo in un certo stato per un certo numero di cicli (e non fermando tutta l’elaborazione per quel tempo come farebbe delay).
Per tenere conto del trascorrere del tempo, o realizzare qualche temporizzazione, in ordine di precisione crescente si può usare:
Si può anche usare una frequenza esterna di riferimento letta tramite un ingresso.
Si vuole usare un interruttore on/off per comandare due LED con sequenza 10-11-01-11 (dove 1=LED acceso). L’interruttore deve accendere, spegnere, e cambiare sequenza a fronte di una rapida apertura/chiusura non superiore a mezzo secondo. Quando almeno uno dei due LED è acceso, un terzo LED deve lampeggiare costantemente 50ms on / 1950ms off. Dopo mezz’ora dall’accensione o dall’ultimo cambio di sequenza, il sistema si deve portare in standby spegnendo tutti i LED. A questo punto per riaccendere bisogna aprire e richiudere l’interruttore.
Abbiamo quindi un ingresso (chiamiamolo INGR) a cui va applicato un adeguato debounce hardware o software. I contatti meccanici infatti ad ogni commutazione generano dei rimbalzi, cioè veloci chiusure e aperture per diversi millisecondi, che se non vengono filtrate possono essere lette come aperture e chiusure indesiderate.
Le uscite le possiamo chiamare LE1, LE2 e LAMP. L’ultima è quella a cui collegare il LED di segnalazione che blinka per 50ms ogni due secondi, e indica che almeno uno degli altri LED è acceso.
Complessivamente le funzionalità del sistema si possono scomporre in almeno tre processi indipendenti che devono essere portati avanti tutti assieme. Ogni processo compie un lavoro ben specifico e distinto da quello degli altri:
Ogni processo ha segnali in ingresso e in uscita. Possono essere segnali hardware (come le porte di ingresso/uscita), o interprocesso. Ci sono tre tipi di segnali interprocesso semplici da implementare:
Prima di tutto definiamo dei “nomi di comodo” da usare nel resto del programma, in modo da non cospargerlo di “numeri magici” (che è facile dimenticare a cosa servono) e di livelli hardware HIGH/LOW (che sono difficili da interpretare senza lo schema elettrico sotto mano). Con le seguenti definizioni in pratica si indica già tutto quello che riguarda i collegamenti hardware (pin usati, collegamenti pull-up o pull-down, livelli letti ecc), nel resto del programma non ci si occupa mai più di loro:
#define INGR .... <--pin Arduino per ingressi e uscite #define LE1 .... #define LE2 .... #define LAMP .... #define CHIUSO .... <--livello a interruttore chiuso #define APERTO !CHIUSO #define SPENTO .... <--livello LED spenti #define ACCESO !SPENTO
Poi ci servono delle variabili globali per “trasportare” i segnali interprocesso (ricordarsi sempre di inizializzare tutte le variabili che servono per una partenza corretta del sistema all’accensione), e bisogna effettuare il settaggio iniziale di ingressi e uscite (anche qui scrivere subito sulle uscite i valori “di riposo” che si vogliono avere all’avvio):
boolean onClose; //true un ciclo quando interruttore chiude boolean onOpen; //true un ciclo quando interruttore apre boolean luciAccese = false; //true se luci accese //---------------------------------------------------------- void setup() { pinMode(INGR, INPUT); pinMode(LE1, OUTPUT); digitalWrite(LE1, SPENTO); pinMode(LE2, OUTPUT); digitalWrite(LE2, SPENTO); pinMode(LAMP, OUTPUT); digitalWrite(LAMP, SPENTO); }
La funzione loop richiama semplicemente i processi:
void loop() { leggiIngresso(); logica(); blink(); }
Questo processo deve controllare se l’ingresso ‘INGR’ varia rispetto
al ciclo precedente, verificare se la variazione rimane stabile per almeno 50 millisecondi,
e in tal caso impostare a true il segnale ‘onClose’
oppure ‘onOpen’ a seconda che la variazione sia una
chiusura o un’apertura dell’interruttore.
Si usa una variabile ‘inPrec’ per contenere l’ultimo livello
stabile rilevato (che all’avvio viene impostata uguale alla lettura attuale
dell’ingresso).
Sono sufficienti due stati, uno di
attesa di una variazione, e uno di “conteggio”, che controlla se
durante 50ms il livello letto ritorna uguale all’ultimo livello stabile, in tal caso
si ritorna in attesa senza effettuare altre azioni.
In questo modo ogni variazione di durata inferiore ai 50ms (come
possono essere quelle generate dai rimbalzi) viene filtrata, e
i segnali ‘onClose’ e ‘onOpen’ vengono generati solo
in presenza di una commutazione reale e stabile.
I segnali ‘onClose’ e ‘onOpen’ vengono
sempre “azzerati” prima di processare gli stati che
possono generarli, questo garantisce che la loro durata sia di un solo ciclo
di programma.
Il seguente diagramma è uno dei vari modi
per rappresentare un processo a stati. Riporta gli stati (cerchi verdi), gli eventi
che causano le transizioni (in blu), e un riassunto delle azioni che vengono eseguite
al verificarsi degli eventi (in rosso).
void leggiIngresso() { static uint32_t t = millis(); static byte stato = 0; static byte inPrec = digitalRead(INGR); byte in = digitalRead(INGR); onClose = false; onOpen = false; switch(stato) { case 0: //attendi if (in != inPrec) { t = millis(); stato = 1; } break; case 1: //timer if (in == inPrec) { stato = 0; } else if (millis()-t >= 50) { stato = 0; inPrec = in; if (CHIUSO == in) { onClose = true; } else { onOpen = true; } } break; } }
Il processo di controllo principale deve avere almeno quattro
diversi stati: “spento”, “acceso”, “timer” (per
riconoscimento comando di cambio sequenza) e “standby”. Viene
comandato dai segnali prodotti da ‘letturaIngresso”, genera il segnale
‘luciAccese’ per comandare il lampeggiatore (processo ‘blink’) e
comanda le uscite LE1 e LE2 con l’aiuto di una piccola funzione esterna
chiamata appunto ‘comanda’.
La funzione ‘comanda’ riceve un argomento, i valori da
da 0 a 3 indicano come devono essere accesi i LED, mentre il valore -1 significa
tutto spento.
Il processo ha una variabile interna ‘seq’ che contiene il valore
da passare a ‘comanda’. Questa variabile viene letta quando
si passa dallo stato ‘spento’ ad ‘acceso’, e viene
incrementata ogni volta che viene riconosciuta la sequenza di cambio (breve apertura e
richiusura dell’interruttore). L’incremento viene effettuato contando in
modulo quattro, per cui i valori assunti dalla variabile variano
circolarmente da 0 a 3.
Quando l’interruttore viene aperto (segnale ‘onOpen’ true) si
transita nello stato ‘timer’. Se l’interruttore rimane aperto
oltre mezzo secondo viene considerato spegnimento e si ritorna allo stato
‘spento’.
Infine nello stato acceso si controlla se
dall’ultimo salvataggio del valore di millis() sono trascorsi trenta minuti
(1800000ms), in tal caso le luci si spengono e si passa allo stato standby,
da cui si esce solo aprendo l’interruttore.
Il processo genera anche il segnale ‘luciAccese’ di tipo livello,
che permane a true durante tutto il tempo in cui almeno un LED è
acceso.
Il seguente diagramma, più simile a un flowchart, è un
secondo modo per rappresentare un processo a stati. Anche qui in ogni
stato sono evidenziate le condizioni e le azioni intraprese quando le condizioni
risultano vere. Le frecce finali indicano per ogni singolo caso lo stato raggiunto.
void comanda(char n) { if (-1 == n) { digitalWrite(LE1, SPENTO); digitalWrite(LE2, SPENTO); } else if (0 == n) { digitalWrite(LE1, ACCESO); digitalWrite(LE2, SPENTO); } else if ((1 == n) || (3 == n)) { digitalWrite(LE1, ACCESO); digitalWrite(LE2, ACCESO); } else if (2 == n) { digitalWrite(LE1, SPENTO); digitalWrite(LE2, ACCESO); } } //---------------------------------------------------------- void logica() { static byte stato = 0; static uint32_t t; static byte seq = 0; switch(stato) { case 0: //spento if (onClose) { comanda(seq); luciAccese = true; t = millis(); stato = 1; } break; case 1: //acceso if (onOpen) { t = millis(); stato = 2; } else if (millis()-t > 1800000L) { comanda(-1); luciAccese = false; stato = 3; } break; case 2: //attesa 500ms if (onClose) { seq = (seq + 1) % 4; comanda(seq); stato = 1; } else if (millis()-t >= 500) { comanda(-1); luciAccese = false; stato = 0; } break; case 3: //standby if (onOpen) { stato = 0; } break; } }
void blink() { static byte stato = 0; static uint16_t t; switch(stato) { case 0: //spento if (luciAccese) { t = millis(); stato = 1; } else { digitalWrite(LAMP, SPENTO); } break; case 1: //acceso if (!luciAccese) { stato = 0; } else if ((uint16_t)millis()-t >= 50) { t += 1950; stato = 2; } else { digitalWrite(LAMP, ACCESO); } break; case 2: //pausa if (!luciAccese) { stato = 0; } else if ((uint16_t)millis()-t >= 1950) { t += 50; stato = 1; } else { digitalWrite(LAMP, SPENTO); } break; } }
Quando si programma un sistema strutturato a stati, quello che si fa normalmente è questo:
Detto questo sono da decidere:
Tutto questo si fa con strutture if/else oppure switch, e variabili
usate opportunamente (come indicatori di stato, contatori, flag ecc)
Il funzionamento/avanzamento del sistema è formato dalla continua rapida ripetizione di
tutti questi punti.
Se oltre a questo c’è anche da tenere conto
del trascorrere del tempo (o si devono realizzare temporizzazioni), in ordine di
precisione crescente si possono usare: