Continuiamo la serie di articoli a scopo didattico sulla realizzazione di un progetto embedded all’interno del nostro FabLab. Questo articolo si concentra sulle tecniche applicate per rendere il codice affidabile, in particolare la continuous integration con microcontrollori ESP32.
Articoli della serie:
- 1) Intro & prototipo HW (parte I)
- 2) Scheda mille-fori (parte II)
- 3) Progettazione PCB (parte III)
- 4) Schema elettrico (parte IV)
- 5) Sponsorship OpenHWLAB e PCB assemblato (parte V)
- 6) Sviluppo embedded con C++ moderno (parte VI)
- 7) Backend Python e Raspberry Pi (parte VII)
- 8) Collaudi del software in ambito embedded (parte VIII, questo articolo)
- 9) La messa in servizio del sistema nel Lab (parte IX)
Puoi consultare le istruzioni per l’uso di Fab-O-Matic qua.
Come testare ?
Non c’è niente di male a testare “a mano” un programma. Nei primi mesi del progetto le modifiche venivano testate scaricando il firmware sulla dev-board e con un Raspberry Pi Zero. Il problema è che, man mano che si aggiungono funzionalità al programma, aumenta il numero di casi da testare: tessera valida, tessera sconosciuta, macchina operativa, macchina bloccata, manutenzione richiesta… inoltre tutti questi casi andrebbero verificati sia con la rete presente che senza la rete, visto che Fab-O-Matic deve rimanere operativo in ogni caso.
Diventa abbastanza lungo (e noioso) ripetere queste operazioni ad ogni modifica o release del firmware. Ma se non vengono fatte, esiste la possibilità che una modifica abbia introdotto un bug, che in certi casi possa bloccare l’uso di una macchina creando insoddisfazione sul sistema.
E se potessimo invece scrivere un programma di test, che verifica il comportamento del software, e eseguirlo ogni volta che cambiamo il codice sorgente ? Per adottare questo approccio di test continuo, occorre scrivere alcune funzioni che creano una condizione iniziale di teste, chiamano le funzioni del programma e verificano se lo stato finale è quello atteso.
Continuous Integration per progetti embedded
Scrivere codice di test non è complesso. Ecco un esempio di codice per automatizzare un test:
// Condizioni iniziali : tessera valida
const auto card_uid = get_test_uid(0);
// Simulazione arrivo tessera e chiamata funzioni da testare
auto new_state = simulate_rfid_card(rfid, logic, card_uid);
// Verifica risultato atteso (macchina non libera, utente loggato)
TEST_ASSERT_EQUAL_UINT16_MESSAGE(BoardLogic::Status::LoggedIn, new_state, "Status not LoggedIn");
TEST_ASSERT_TRUE_MESSAGE(!logic.getMachine().isFree(), "Machine is free");
Il valore del tempo speso a scrivere tests aumenterà con il tempo, quando i tests vi daranno la sicurezza di poter modificare una classe critica o la serenità di poter installare una nuova release del software.
Ma come eseguirlo questo testcase senza hardware connesso ? E come eseguirlo ogni volta che viene modificato il programma ? Il codice è interamente scritto per librerie embedded e non esiste una soluzione semplice per eseguirlo sul proprio computer, dove mancano concetti di SPI Bus, GPIO e non esiste un framework Arduino completo.
Per raggiungere questi obiettivi con dei microcontrollori, bisogna fare un percorso abbastanza lungo. Questo articolo ha lo scopo di spiegarvelo per i vostri progetti e consentire al vostro software di fare un salto di qualità.
Eseguire codice ESP32 su PC ?
La prima difficoltà da superare quando si vuole testare automaticamente un programma per microcontrollore è proprio l’assenza del microcontrollore connesso alla macchina di test, a maggiore ragione se i test sono da eseguire su infrastrutture remote come GitHub.
I problemi da risolvere sono i seguenti:
① Trovare un emulatore del microprocessore con supporto delle istruzioni XTensa usate da ESP32/ESP32S2/ESP32S3, le immagini della ROM di fabbrica corrispondenti e la capacità di rimandare la seriale di debugging sulla console. Inoltre, bisogna poter caricare il firmware generato per eseguire i test. 💡Soluzione
② Gli emulatori non simulano (in generale) le periferiche connesse sul PCB come il lettore di tag RFID o LCD 1602. Inoltre, L’ESP32 è un microcontrollore piuttosto complesso con molte periferiche integrate : WiFi, bus SPI, flash memory. Fab-O-Matic è un dispositivo connesso, pertanto simulare il WiFi è richiesto. 💡Soluzione
③ Ogni unità di test dovrà ritornare un codice di ritorno così da informare lo script di test (GitHub Action) e generare un log delle operazioni fatte, per consentire il debugging. 💡Soluzione
④ I test devono essere “portabili” : eseguibili sia sul computer dello sviluppatore che su GitHub, con gli stessi risultati. Qua ci verrà in aiuto PlatformIO, che fornisce funzionalità native per il testing, nonché alcune estensioni VSCode.
⑤ I tests devono usare lo stesso linguaggio del resto del progetto embedded, così da poter esercitare ogni classe del progetto con il minimo sforzo. Tuttavia, questi testcase non devono essere inclusi nell’immagine finale per limitare le dimensioni. In ambito embedded non vogliamo sprecare bytes !💡Soluzione
⑥ Per velocizzare l’analisi dei problemi, i testcases devono essere suddivisibili in test individuali all’interno di unità di test. 💡Soluzione
⑦ Date le imperfezioni della simulazione con le periferiche, il codice di Fab-O-Matic deve essere adattabile per poter funzionare in un ambiente “simulato” o in un ambiente “reale” – ovviamente con il minimo di modifiche per non invalidare tutto l’approccio di test ! 💡Soluzione
⑧ Fab-O-Matic ha bisogno di parlare con il server backend in MQTT. Serve aggiungere un broker e un “backend” di test per poter verificare il comportamento del software nei vari casi di utilizzo (macchina bloccata, tessera sconosciuta, ecc). 💡Soluzione
Il resto dell’articolo esamina come risolvere questi vari problemi con dei strumenti comuni. Non è detto che la soluzione presentata sia unica, o la migliore in assoluto, ma la riteniamo valida e robusta.
Scrivere dei test per Fab-O-Matic : Unity
PlatformIO fornisce un framework di test, per usarlo è sufficiente creare una cartella test nel progetto, includere l’header Unity.h nel proprio file C++ , e indicare il framework in platformio.ini
[env]
platform = https://github.com/tasmota/platform-espressif32/releases/download/2024.08.10/platform-espressif32.zip
framework = arduino
test_framework = unity
Unity richiede le solite due funzioni di Arduino, setup() e loop(). E’ possibile mettere tutto il codice in setup, perché la macro UNITY_END()
termina in ogni caso l’esecuzione.
#include <Arduino.h>
#define UNITY_INCLUDE_PRINT_FORMATTED
void setup()
{
delay(1000);
esp_log_level_set(TAG4, LOG_LOCAL_LEVEL); // Per abilitare log di debug
UNITY_BEGIN();
RUN_TEST(fabomatic::tests::test_steady_clock);
UNITY_END(); // stop unit testing
}
void loop()
{
}
Ogni test da eseguire nel programma di test va elencato tramite la macro RUN_TEST()
. Nell’esempio di sopra, il test comprende un unico testcase, fabomatic::tests::test_steady_clock
.
All’interno di un singolo test, si usano le funzioni TEST_ASSERT_xxx_MESSAGE
per verificare se il valore osservato corrisponde al valore atteso. Ecco un esempio più comprensivo:
void test_check_transmission()
{
auto &server = logic.getServer();
TEST_ASSERT_TRUE_MESSAGE(server.hasBufferedMsg(), "(1) There are pending messages");
TEST_ASSERT_TRUE_MESSAGE(server.connect(), "Server connect works");
TEST_ASSERT_TRUE_MESSAGE(server.hasBufferedMsg(), "(2) There are pending messages");
TEST_ASSERT_TRUE_MESSAGE(server.alive(), "Alive request works");
// Since old messages must be sent first, the buffer shall be empty now
TEST_ASSERT_FALSE_MESSAGE(server.hasBufferedMsg(), "There are no more pending messages");
TEST_ASSERT_TRUE_MESSAGE(server.saveBuffer(), "Saving pending messages works");
}
Questi test sono compilati da PlatformIO per il microcontrollore ESP32 e possono essere eseguiti sia sull’hardware reale connesso sulla porta USB, che in simulazione (come descritto nel paragrafo successivo) tramite il comando pio test
che provvede ai vari build, upload, esecuzione in sequenza dei testcase e reportistica dei risultati.
Simulare il microprocessore
Il framework ESP-IDF fornisce un fork QEMU e delle istruzioni dettagliate per lanciare la simulazione, ma la difficoltà rimane alta, in particolare con la gestione della seriale sulle varie piattaforme di esecuzione, che è necessaria per valutare l’esito del test.
Esiste invece un modo immediato di testare e simulare online microprocessori Arduino, ESP32, STM32 : la piattaforma Wokwi. Offre in particolare la simulazione della rete, la possibilità di collegare un debugger (come GDB, di cui è stato fatto un corso all’ultimo Linux Day 2023). Wokwi offre alcune periferiche simulabili : relay, LCD 16×02, LED, pulsanti…
Con l’estensione VSCode diventa possibile testare molto rapidamente molte modifiche. Alcune features di wokwi necessitano una licenza, ma quest’ultima è gratuita per progetti open-source.
Per usare la simulazione, premere F1 in Wokwi, selezionare Upload Firmware… e caricare il file BIN contenuto nella release Wokwi da GitHub (file ZIP).
Il circuito viene salvato in un file diagram.json. Esiste anche un file TOML di configurazione che è importante per gli altri tools di Wokwi.
Per eseguire i testcase all’interno dell’ambiente di sviluppo VSCode con l’estensione Wokwi, occorre selezione l’ambiente di build (-e wokwi
), indicare a PlatformIO di non tentare il flash sulla seriale (--without-uploading
), di non eseguire automaticamente il test (--without-testing
) in modo di ottenere il file firmware.bin da lanciare in modo interattivo. E’ anche possibile eseguire selettivamente alcuni tests, con l’argomento (-f <file_cpp>
).
pio test -e wokwi --without-uploading --without-testing -f test_tasks
Automatizzare la simulazione Wokwi-cli
Con l’istruzione di sopra abbiamo generato senza intervento un firmware di test, ma è stato necessario lanciare a mano la simulazione nell’IDE. Fortunatamente, Wokwi fornisce anche un’utilità di command-line : wokwi-cli per lanciare la simulazione del file BIN.
Wokwi-cli usa lo steso file TOML dell’estensione VSCode, pertanto è sufficiente lanciarlo dalla directory di progetto per vedere nella console l’output della seriale del microprocessore ESP32.
Una volta catturato l’output, l’esito PASS/FAIL del test può essere desunto da alcune stringhe generate da Unity: 'Tests 0 Failures 0 Ignored'
indicherà un test concluso con successo, mentre ':FAIL:'
segnala l’errore. L’utilità di command-line è stata progetta con i test automatici in mente, e fornisce gli argomenti --expect-text
e --fail-text
per fornire queste stringhe caratteristiche dell’esito.
Testare automaticamente ad ogni commit del codice
L’ultimo e finale step in questo percorso consiste nell’usare una Github Action per eseguire wokwi: wokwi/wokwi-ci-action: Use the Wokwi Embedded Systems Simulator in your CI workflow (github.com). L’autore di Wokwi ha accettato la nostra pull-request per aggiungere la funzionalità di salvataggio su file dell’output della seriale, che è molto comodo per analizzare perché è fallito un test nel cloud senza dover rieseguirlo localmente.
La Github Action finale definisce una matrice di test e esegue per ogni elemento il build con PlatformIO e il test con wokwi-ci-action.
strategy:
matrix:
tests:
- test_mqtt
- test_logic
- test_savedconfig
- test_tasks
- test_chrono
[...]
- name: Build ${{ matrix.tests }}
run: pio test -e wokwi --without-testing --without-uploading -f ${{ matrix.tests }}
- name: Run ${{ matrix.tests }} with Wokwi CLI
id: wokwi-ci
uses: wokwi/wokwi-ci-action@v1
with:
token: ${{ secrets.WOKWI_CLI_TOKEN }}
path: / # directory with wokwi.toml, relative to repo's root
timeout: 240000
expect_text: 'Tests 0 Failures 0 Ignored'
fail_text: ':FAIL:'
serial_log_file: '${{ matrix.tests }}.log'
Mockup in C++
Quando viene eseguito un test, non è simulato il microprocessore esterno MFRC522 che rileva e scambia dati con una tessera RFID. Di conseguenza, servirebbe potere sostituire la parte di codice che interagisce con il dispositivo con un simulacro (“mock-up”). In programmazione ad oggetti l’approccio classico sarebbe di definire un’interfaccia comune a due classi (magari IMrfc522
) e due classi MockMrfc522
e Mrfc522Driver
che ereditano da questa interfaccia : la classe che usa i loro servizi (RFIDWrapper
) dispone solo di un riferimento all’interfaccia IMrfc522
e con una semplice logica di initializzazione sceglie quale delle due classi usare.
Funziona benissimo questa tecnica, ma in C++, si può evitare l’interfaccia comune e l’overhead delle funzioni virtuali grazie ai templates. La classe che usa i loro servizi (RFIDWrapper) può prendere come template il driver da usare. Se i metodi delle due classi differiscono, il codice in RFIDWrapper non compilerà più. Basta un semplice flag di compilazione per generare codice ottimizzato, senza pagare nessun prezzo per il mockup.
#if (RFID_SIMULATION)
RFIDWrapper<MockMrfc522> rfid{};
#else
RFIDWrapper<Mrfc522Driver> rfid{};
#endif
Possiamo per illustrare il metodo di mock-up comparare due implementazioni dello stesso metodo PICC_IsNewCardPresent
che verificano la presenza di una tessera RFID.
Il primo, simulato, ritorna true se è stato impostato il valore di un campo nascosto ad uso del testcase:
std::optional<card::uid_t> getSimulatedUid() const;
auto MockMrfc522::PICC_IsNewCardPresent() -> bool { return getSimulatedUid().has_value(); }
Il vero driver invece, delega alla libreria Arduino MFRC522v2 la verifica della presenza di una tessera:
std::unique_ptr<MFRC522> mfrc522;
bool Mrfc522Driver::PICC_IsNewCardPresent() { return mfrc522->PICC_IsNewCardPresent(); }
Librerie aggiuntive per la simulazione “autonoma”
Fab-O-Matic nel funzionamento normale sottoscrive alcuni topic MQTT, ma nell’ambiente di simulazione questo broker non è disponibile. E’ possibile aggiungere Mosquitto nelle azioni Github, ma questo rallenta parecchio l’operazione e complica la configurazione della board. Inoltre, non basta un broker per una simulazione completa: occorre anche simulare le (semplici) risposte del back-end.
Grazie alla potenza di calcolo dell’ESP32, e il supporto dei socket TCP/IP di Wokwi, è possibile un’altro approccio: eseguire in uno thread indipendente un broker MQTT al quale si collegherà l’altro thread di Fab-O-Matic.
Un broker MQTT per Arduino è terrorsl/sMQTTBroker: Simple MQTT broker (github.com) ; per farlo partire in uno thread indipendente tramite la funzione C standard pthread_create
.
void startMQTTBrocker()
{
static pthread_t thread_mqtt_broker;
static pthread_attr_t attr_mqtt_broker;
// Start MQTT server thread in simulation
attr_mqtt_broker.stacksize = 3 * 1024; // Required for ESP32-S2
attr_mqtt_broker.detachstate = PTHREAD_CREATE_DETACHED;
if (pthread_create(&thread_mqtt_broker, &attr_mqtt_broker, threadMQTTServer, NULL))
{
ESP_LOGE(TAG, "Error creating MQTT server thread");
}
}
Infine, per evitare di appesantire i build delle versioni non dedicate ai collaudi, si possono usare due funzionalità: l’inclusione di librerie limitatamente ad un ambiene (sotto, env:wokwi
aggiunge https://github.com/terrorsl/sMQTTBroker.git@0.1.8
, mentre gli altri ambieni no) e un flag per il preprocessore, MQTT_SIMULATION=true
.
[env:wokwi]
board = esp32-s2-saola-1
lib_deps = ${env.lib_deps}
https://github.com/terrorsl/sMQTTBroker.git@0.1.8
build_type = debug
build_src_flags = ${env.build_src_flags}
-D MQTT_SIMULATION=true
-D RFID_SIMULATION=true
Conclusione
Questo articolo conclude gli approfondimenti sulla progettazione e realizzazione del sistema Fab-O-Matic. L’ultimo articolo coprirà le modalità di connessioni di Fab-O-Matic alle varie tipologie di macchine presenti nel FabLab e la sua messa in servizio. A presto!