Benvenuti alle dispense di TPSI - Stazione di monitoraggio ambientale
In queste dispense vedremo come implementare un semplice progetto IoT: una stazione di monitoraggio ambientale.
Ricordatevi che il lavoro dell'informatico non è aggiustare computer, passare dei cavi o scrivere un'applicazione. Il nostro mestiere è aiutare le persone a risolvere problemi. Dobbiamo quindi sempre avere bene in mente quale problema stiamo risolvendo e per chi, per fare in modo che il nostro lavoro sia veramente utile agli altri.
Scenario
Una scuola vuole monitorare la qualità dell'aria per attuare eventuali azioni di riduzione dell'inquinamento.
Utenti e dimensionamento
Individuiamo i seguenti utenti del servizio:
- Responsabili qualità dell'aria (personale della scuola, docenti o ATA): circa 6
- Data scientists (docenti e studenti): circa 3
Ipotizziamo inoltre che le stazioni di monitoraggio all'interno della scuola saranno circa 6.
Dispositivi e casi d'uso
Individuiamo i seguenti dispositivi e casi d'uso:
- i responsabili qualità dell'aria useranno principalmente uno smartphone con una pagina web per la visualizzazione dei dati, collegati ad internet tramite Wi-Fi, della scuola o personale
- i data scientists useranno un computer fisso con delle applicazioni locali (es. programma in Python), possono collegarsi sia dai computer di scuola che di casa
Per quanto riguarda la stazione di monitoraggio, ipotizziamo l'uso di una scheda ESP32 connessa direttamente con i sensori per il monitoraggio ambientale e alla rete scolastica tramite Wi-Fi. Come estensione futura, possiamo pensare ad una connessione LoraWAN, a maggior raggio e minori consumi.
Architettura di rete
- WebServer
- Application Server (Node-RED)
- DB SQL (MySQL)
- DB NoSQL (MongoDB)
La scuola ha già a disposizione un web server, un server Node-RED ed un DBMS con MySql, quindi si decide di usare l'infrastuttura già esistente per ridurre i costi.
Per il DB NoSQL invece, non ancora presente a scuola, si decide per il momento di usare un servizio in cloud, in modo da poter testare a basso costo o gratuitamente il servizio per poi valutare se importarlo all'interno dei server della scuola.
Note sul tutorial
In questo tutorial, per comodità useremo tutti servizi in cloud anche per quelli on-premises.
In particolare, come application server useremo Node-RED su una macchina AWS-EC2 con Ubuntu. In ogni caso è facilmente replicabile on-premises seguendo le stesse istruzioni ma con una macchina Ubuntu locale.
Wokwi
Per la simulazione della scheda, useremo Wokwi, che permette una simulazione molto fedele, è semplice da usare e la parte gratuita permette di fare tutto quello che ci serve.
Come detto nello scenario, useremo una ESP32; per semplicità la qualità dell'aria viene monitorata con il solo sensore di temperatura ed umidità DHT22.
L'ESP32 è un ottima scelta per IoT perché permette, in un progetto reale, di criptare facilmente i dati e risparmiare energia con la funzionalità deep sleep.
Creiamo una stazione di monitoraggio ambientale virtuale con Wokwi ed MQTT:
- accedere a wokwi.com
- fare la copia di questo progetto
- cambiare
MQTT_CLIENT_ID
con qualcosa di personale ed unico - cambiare
MQTT_TOPIC
inmarconi-stazione-2024
- opzionale: cambiare
SENSOR_LOCATION
scegliendo tra "Atrio", "Parcheggio" e "Giardino" - premere il tasto Play e la nostra stazione è pronta!
Note sul progetto:
- il protocollo NTP permette di leggere l'ora UTC reale, opzionalmente si potrà in futuro aggiungere anche il fuso orario
- il messaggio varierà sempre perché cambia il timestamp, il controllo per l'aggiornamento serve principalmente per evitare di mandare più volte per errore lo stesso messaggio
- per debug ora l'intervallo di invio è 10 secondi, nel progetto reale si può fare un intervallo molto più ampio (es. 2 ore) e usare la funzionalità deep sleep per ridurre il consumo energetico in questo intervallo
Per testare che sta andando tutto bene, andare su https://www.hivemq.com/demos/websocket-client/, lasciare le impostazioni di default e premere "Connect". Sottoscriversi allo stesso topic del simulatore, quindi provare a cambiare i valori ambientali su wokwi cliccando sul sensore e variando i dati di temperatura ed umidità, dovreste vedere arrivare i messaggi sul vostro client web.
Riferimento codice
Metto qui sotto lo stesso codice usato nel progetto copiato sopra, come riferimento.
"""
MicroPython IoT Weather Station Example for Wokwi.com
To view the data:
1. Go to http://www.hivemq.com/demos/websocket-client/
2. Click "Connect"
3. Under Subscriptions, click "Add New Topic Subscription"
4. In the Topic field, type "wokwi-weather" then click "Subscribe"
Now click on the DHT22 sensor in the simulation,
change the temperature/humidity, and you should see
the message appear on the MQTT Broker, in the "Messages" pane.
Copyright (C) 2022, Uri Shaked
https://wokwi.com/arduino/projects/322577683855704658
"""
import network
import time
import ntptime
from machine import Pin
import dht
import ujson
from umqtt.simple import MQTTClient
# MQTT Server Parameters
MQTT_CLIENT_ID = "micropython-weather-capobianco-ac516af1"
MQTT_BROKER = "broker.mqttdashboard.com"
MQTT_USER = ""
MQTT_PASSWORD = ""
MQTT_TOPIC = "marconi-stazione-2024"
SENSOR_LOCATION = "Giardino"
sensor = dht.DHT22(Pin(15))
print("Connecting to WiFi", end="")
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
sta_if.connect('Wokwi-GUEST', '')
while not sta_if.isconnected():
print(".", end="")
time.sleep(0.1)
print(" Connected!")
print("Connecting to MQTT server... ", end="")
client = MQTTClient(MQTT_CLIENT_ID, MQTT_BROKER, user=MQTT_USER, password=MQTT_PASSWORD)
client.connect()
print("Connected!")
print("Sync time with NTP... ", end="")
ntptime.settime()
print("Sync!")
prev_weather = ""
while True:
print("Measuring weather conditions... ", end="")
sensor.measure()
anno, mese, giorno, ora, minuti, secondi, giorno_settimana, anno_settimana = time.localtime()
message = ujson.dumps({
"location" : SENSOR_LOCATION,
"temp": sensor.temperature(),
"humidity": sensor.humidity(),
"timestamp": f"{anno}-{mese:02d}-{giorno:02d}T{ora:02d}:{minuti:02d}:{secondi:02d}"
})
if message != prev_weather:
print("Updated!")
print("Reporting to MQTT topic {}: {}".format(MQTT_TOPIC, message))
client.publish(MQTT_TOPIC, message)
prev_weather = message
else:
print("No change")
time.sleep(10)
Sempre per riferimento, inserisco anche il diagram.json
.
{
"version": 1,
"author": "Uri Shaked",
"editor": "wokwi",
"parts": [
{ "type": "board-esp32-devkit-c-v4", "id": "esp", "top": -38.4, "left": -100.76, "attrs": {} },
{
"type": "wokwi-dht22",
"id": "dht1",
"top": -38.1,
"left": 42.6,
"attrs": { "temperature": "58.2" }
}
],
"connections": [
[ "esp:TX", "$serialMonitor:RX", "", [] ],
[ "esp:RX", "$serialMonitor:TX", "", [] ],
[ "dht1:VCC", "esp:3V3", "red", [ "v109.3", "h-170.36", "v-200.78" ] ],
[ "dht1:SDA", "esp:15", "green", [ "v0" ] ],
[ "dht1:GND", "esp:GND.1", "black", [ "v99.7", "h-189.56", "v-66.38" ] ]
],
"dependencies": {}
}
Installazione Node-RED
Continuando il lavoro fatto nelle scorse settimane, installeremo un'istanza di Node-RED su una macchina EC2 di AWS. Seguiremo questa guida.
Lanciare una macchina EC2 come segue:
- Ubuntu Server
- t2.micro
- aprire le porte 1880, 80 e per utilità anche "All ICMP", come sorgente usate "Any"
- come chiave sempre
vockey.pem
Installare Node-RED come segue (attenzione, rispetto alla guida siamo passati alla versione 21):
curl -sL https://deb.nodesource.com/setup_21.x | sudo -E bash -
sudo apt-get install -y nodejs build-essential
sudo npm install -g --unsafe-perm node-red
Ricaricare la pagina per fare in modo che le modifiche abbiano effetto.
Ora bisogna installare anche i nodi che ci serviranno per MongoDB:
mkdir -p ~/.node-red/
cd ~/.node-red/
npm install node-red-node-mongodb
Infine avviare Node-RED:
node-red
Adesso è possibile accedere alla propria istanza con andando su http://<your-instance-ip>:1880/
.
Opzionale: avvio automatico
Per fare in modo che Node-RED vada in esecuzione automaticamente all'avvio della macchina virtuale, eseguire i seguenti comandi:
sudo npm install -g --unsafe-perm pm2
pm2 start `which node-red` -- -v
pm2 save
pm2 startup
Attenzione: l'ultimo comando, alla fine vi chiederà di eseguire un ulteriore comando - assicuratevi di eseguirlo come richiesto
Flow MQTT
Cominciamo a configurare il nostro "flow" di Node-RED.
Cerchiamo nella casella di ricerca in alto a sinistra "MQTT" e trasciniamo nella pagina il nodo "MQTT in".
Il nodo appena messo ha un triangolo arancione, vuol dire che per funzionare deve essere configurato. Clicchiamoci sopra per farlo.
Cliccare sulla matita vicino ad "Add new mqtt-broker". Come Name mettere HiveMQ
e come server mettere broker.mqttdashboard.com
.
Premere add, quindi nella schermata successiva mettere il nostro topic di interesse, sempre marconi-stazione-2024
.
Premere "Done" A questo punto il nodo è configurato. Ora aggiungiamo anche un nodo di Debug per vedere se effetivamente sta ricevendo i messaggi.
Il nostro flow è finito, ma come vedete dai pallini azzurri sopra i nodi, non è ancora attivo. Per renderlo attivo bisogna fare il "Deploy" (traducibile con "dislocazione" in italiano) cliccando sul tasto in alto a destra. Ora il flow è in esecuzione!
Cliccando nel pannello a destra sul ragno si apre la schermata con le stampe di debug. Ora dalla stazione simulata su wokwi cambiare la temperatura e controllare che il nuovo messaggio arrivi su Node-RED.
MongoDB
Come database NoSQL in cloud, useremo MongoDB.
MongoDB is a source-available, cross-platform, document-oriented database program. Classified as a NoSQL database product, MongoDB utilizes JSON-like documents with optional schemas. MongoDB is developed by MongoDB Inc. and current versions are licensed under the Server Side Public License.
Fare il login con le credenziali Google della scuola, rispondendo alle domande per fini statistici.
Creare un nuovo progetto:
- Database M0 (Free)
- Nome progetto: StazioneMonitoraggioAmbientale
- Togliere le spunte "Automate scecurity setup" e "Add sample dataset"
- Cliccare su "Create Deployment"
- In "Add a connection IP address" selezione "Allow Access from Anywhere", quindi "Add IP Address"
- Su "Create a database user": come username usare nomecognome (tutto attaccato, senza punto) e come password mettere Scuola100, quindi premere su "Create database user"
- Andare avanti
Se vi doveste essere persi alcuni dei passaggi prima, potete rimediare anche successivamente
- Per impostare il firewall, nel pannello a sinistra, nella sezione Security, selezionare Network Access
- Cliccare su Add IP Address, quindi "Allow Access from Anywhere" (
0.0.0.0/0
)
Connessione a Node-RED
Nella pagina "Connect", selezionare "Drivers"
Nella pagina seguente, copiare solo la parte dell'hostname, come in figura.
Tornare su Nodered, aggiungere il nodo "mongodb out", quindi cliccarci sopra per configurarlo e cliccare sulla matita per configurare il server. Impostare come segue:
- Host: incollare l'hostname copiato
- Connection topology: selezionare "DNS Cluster (mongodb+srv://)"
- Database: "stazione"
- Username: il vostro username (nomecognome)
- Password: la vostra password (Scuola100)
- Name: opzionalmente potete mettere un nome al server
Tornati nella pagina precedente, impostate:
- Collection: "sensori"
- Operation: "insert"
- Impostare la flag "Only store msg.payload object"
Connettete il vostro nodo all'input MQTT e la configurazione è finita!
Provate con Wokwi a generare dei dati. Quindi andate su MongoDB Atlas per controllare che sia tutto OK.
Cliccate su Database->Browse Collections e dovreste vedere i vostri dati.
Pagina web
La progettazione della pagina web, in un approccio User Centered Design, parte dall'utente. Creiamo quindi una "Persona" (plur. Personas) che dovrà usare l'app. Cominciamo dal responsabile della qualità dell'aria.
Persona
Nome: Maria De Baselli
Vive e lavora a: Civitavecchia
Impiego: docente (vicepreside)
Maria vuole controllare la qualità dell'aria per assicurarsi che non superi le soglie consentite dalla legge.
Wireframe
Dopo aver definito la persona, passiamo all'implementazione del wireframe. Ricordiamo che nel wireframe dobbiamo immaginarci come se dovessimo fare uno "screenshot" in bianco e nero delle pagine che ci interessano, così come appariranno all'utente finale. In altre parole, il testo e la disposizione degli elementi deve essere il più verosimile possibile. Per quanto riguarda i colori, si possono specificare a margine solo se questi hanno un significato importante dal punto di vista dell'esperienza utente. Ad esempio nel nostro caso è importante specificare che il colore dello sfondo di alcune celle deve essere in accordo con la qualità dell'aria a cui si riferisce.
A questo punto possiamo "tagliare" la pagina in elementi HTML per poi passare alla fase di implementazione. Nel nostro caso, decidiamo di fare la tabella usando regole CSS anziché il tag <table>
, in modo che possa essere più facilmente reso responsive ed adattabile agli schermi di diversi dispositivi.
In particolare per la tabella decidiamo di usare la proprietà display:flex
, in modo da poter disporre in futuro le informazioni non necessariamente in forma tabellare ma anche in altro modo, ad esempio come cards.
Implementazione della pagina
Per prima cosa implementiamo il codice HTML con la struttura della pagina.
<nav>Marconi qualità aria</nav>
<div id="sensor-list">
<div class="sensor" data-id="1">
<div class="sensor-location">Parcheggio</div>
<div class="sensor-quality bad">Scadente</div>
</div>
<div class="sensor" data-id="2">
<div class="sensor-location">Atrio</div>
<div class="sensor-quality good">Buona</div>
</div>
<div class="sensor" data-id="3">
<div class="sensor-location">Giardino</div>
<div class="sensor-quality optimal">Ottima</div>
</div>
</div>
Note sull'HTML:
data-id
è un attributo che serve per identificare non in maniera univoca un elemento HTML nella pagina, ma un dato specifico all'interno di una lista di dati. Questo valore poi verrà assegnato alla chiave primaria del dato, in modo da poterlo manipolare successivamente (es. per aggiornarlo o cancellarlo)
Ora passiamo alla parte CSS:
body {
margin: 0;
}
nav {
background-color: #2b57b6;
color: white;
padding: 0.3rem;
}
#sensor-list {
display: flex;
flex-direction: column;
background-color: #f5f1ed;
}
.sensor {
display: flex;
flex-direction: row;
flex-grow: 1;
}
.sensor > div {
flex-grow: 1;
flex-basis: 0px;
}
.good {
background-color: orange;
}
.bad {
background-color: red;
}
.optimal {
background-color: green;
}
Note sul CSS:
- abbiamo implementato in questo caso due display flex annidati: uno per la lista sensori, una per gli elementi di ogni sensore; anche una soluzione con display grid sarebbe stata accettabile
flex-grow: 1; flex-basis: 0px;
serve per fare in modo che tutte le celle abbiano la stessa larghezza e che non si adattino al contenuto (comportamento di default)
Ora abbiamo la nostra pagina che ha l'aspetto che desideriamo. È largamente migliorabile ma comunque per ora accettabile.
Rendere la pagina dinamica
La pagina finora è statica, dobbiamo renderla dinamica.
Per renderci indipendenti dal sistema reale di generazione dati, che potrebbe essere momentaneamente non disponibile o avere altri problemi, creiamo per ora dei dati "mockup" che siano sempre disponibili e costanti. Useremo GitHub Gist per questa operazione.
Andiamo su gist.github.com e creiamo un public gist, lo nominiamo quality.json
e scriviamo il seguente esempio di dati:
[
{ "location":"Parcheggio","quality":"bad","id":1},
{ "location":"Atrio","quality":"good","id":2},
{ "location":"Giardino","quality":"optimal","id":3}
]
A questo punto possiamo scrivere la parte JS che prende questo dato e crea dinamicamente gli elementi.
fetch("https://gist.githubusercontent.com/<username>/<hash>/quality.json")
.then((response)=>response.json())
.then((json)=> {
console.log(json);
let sensorList = document.getElementById("sensor-list");
json.forEach((sensor) => {
sensorList.innerHTML += `<div class="sensor" data-id="${sensor.id}">
<div class="sensor-location">${sensor.location}</div>
<div class="sensor-quality ${sensor.quality}"></div>
</div>`;
});
});
Note sul JS:
- il link esatto al vostro gist lo potete ottenere premendo su "Raw" e quindi copiando il link a questa pagina; deve avere una struttura simile a quella dell'esempio
- la funzione
fetch()
fa una chiamata HTTP GET e quando è completa esegue la funzione passata come argomento a.then()
- il primo
then()
estrae il JSON dalla response (che di base è una stringa semplice) e chiama lathen()
successiva - visto che il JSON non contiene le stringhe "Buona", "Scadente", etc., in quanto dipendono dalla lingua del browser, le aggiungiamo con le seguenti regole CSS, da aggiungere alla fine:
.good::after {
content: "Buona";
}
.bad::after {
content: "Scadente";
}
.optimal::after {
content: "Ottima";
}
Ricordiamoci di commentare nell'HTML la parte che è stata resa dinamica, che non ci serve più.
Qui il link alla versione finale.
Web-API
A questo punto siamo pronti per completare il nostro servizio connettendo la pagina web a Node-RED.
Creiamo un nuovo flow che chiamiamo "Web Server" e mettiamo i seguenti nodi:
Nel primo nodo (HTTP in), impostiamo come URL "quality".
Nel secondo nodo, il template, copiamo incolliamo il codice da codepen, aggiungendo il tag <style>
dentro l'head ed il tag <script>
subito prima della fine del body, il risultato sarà qualcosa del genere.
<!DOCTYPE html>
<html>
<head>
<title>Qualità aria</title>
<style>
body {
margin: 0;
}
nav {
background-color: #2b57b6;
color: white;
padding: 0.3rem;
}
#sensor-list {
display: flex;
flex-direction: column;
background-color: #f5f1ed;
}
.sensor {
display: flex;
flex-direction: row;
flex-grow: 1;
}
.sensor > div {
flex-grow: 1;
flex-basis: 0px;
}
.good {
background-color: orange;
}
.bad {
background-color: red;
}
.optimal {
background-color: green;
}
.good::after {
content: "Buona";
}
.bad::after {
content: "Scadente";
}
.optimal::after {
content: "Ottima";
}
</style>
</head>
<body>
<nav>Marconi qualità aria</nav>
<div id="sensor-list">
</div>
<script>
fetch("/quality.json")
.then((response)=>response.json())
.then((json)=> {
console.log(json);
let sensorList = document.getElementById("sensor-list");
json.forEach((sensor) => {
sensorList.innerHTML += `<div class="sensor" data-id="${sensor.id}">
<div class="sensor-location">${sensor.location}</div>
<div class="sensor-quality ${sensor.quality}"></div>
</div>`;
});
});
</script>
</body>
</html>
Attenzione: abbiamo cambiato l'URL della fetch per prendere un JSON in locale, prima di poter verificare che la pagina funzioni dobbiamo aggiungere questo URL, che in termine tecnico viene anche detto "endpoint"
Data endpoint
Creiamo un nuovo flow e chiamiamolo "Endpoint". Qui metteremo i dati letti dal database MongoDB. Prima però di fare la connessione al DB, creiamo un endpoint statico di test per verificare che tutto funzioni.
Endpoint statico (di test)
Aggiungiamo nuovamente i tre nodi "html in", "template" e "html response". Facciamo le seguenti modifiche:
- in "html in", inserire come URL
/quality.json
- in "template", modificare il "Syntax Highlight" e selezionare "JSON"
- copiare il json che abbiamo già usato qui e inserirlo nella casella di testo
- modificare "Output as" e selezionare "Parsed JSON"
A questo punto possiamo provare se la pagina funziona andando alla pagina http://<nodered-ip>:1880/quality
Endpoint reale
Richiesta al database
Per fare la richiesta al DB, usiamo la sequenza di nodi "inject" -> "function" -> "mongodb in" -> "Debug", con le seguenti configurazioni:
- in inject, cancelliamo tutti i campi (payload e topic)
- in function, diamo il nome "Find" ed inseriamo il seguente codice:
msg.payload = {"timestamp": {"$exists": true}}
msg.sort = {
timestamp: -1
}
msg.limit = 10;
return msg;
In questo modo troviamo solo i record che hanno un timestamp, li ordiniamo dal più recente e limitiamo la ricerca a 10
- in mongodb in, selezionare il server che avevamo già creato precedentemente, come collezione inserire "sensori", come operazione "find" e come nome "MongoDB Atlas"
- in debug, cambiamo nome e lo chiamiamo "mongodb find"
A questo punto, premendo il pulsante sul lato sinistro del blocco inject possiamo avviare il flusso e controllare che compaia la stampa di debug con i risultati del database.
Calcolo della qualità dell'aria
La chiamata al database ritorna i valori misurati dai sensori, ma a noi serve la qualità dell'aria: dobbiamo quindi scrivere una funzione di mappatura.
Questo è il primo momento del progetto in cui affrontiamo la business logic: finora abbiamo fatto solo connessioni tra elementi, mentre ora dobbiamo mettere dell'intelligenza all'interno del nostro codice.
In particolare, il problema che stiamo affrontando si chiama di classificazione, in quanto dobbiamo assegnare delle "classi" ad un insieme di valori di input. Per questo genere di problemi, il machine learning è particolarmente potente, perché permette di risolvere problemi anche molto complessi in modo relativamente semplice ed efficiente. Nel nostro caso però dobbiamo usare un metodo più semplice, ed useremo quindi un approccio basato su soglie.
Anche in una situazione semplificata come questa però, è buona norma affidarsi a dei documenti ufficiali che diano un valore alla nostra logica. Dalla letteratura disponibile su Internet [fonte?], possiamo considerare una situazione ottimale tra i 19°C ed i 26°C gradi, con una umidità tra il 40% ed il 60%. Possiamo allargare un po' questi intervalli per la situazione buona, tutto il resto è cattiva qualità.
Aggiungiamo quindi un nodo funzione ed un blocco di debug.
Nella funzione possiamo mettere il seguente codice.
// Map array
let locations = [];
let result = [];
msg.payload.forEach((element) => {
// Se il luogo è già stato aggiunto, non aggiungerlo di nuovo
if (locations.includes(element.location)) {
return;
}
locations.push(element.location);
// Convertire i valori dei sensori in qualità dell'aria
let quality = "";
if ((element.temp > 19) && (element.temp < 26)
&& (element.humidity > 40) &&(element.humidity < 60)){
quality = "optimal";
} else if ((element.temp > 16) && (element.temp < 29)
&& (element.humidity > 30) &&(element.humidity < 70)){
quality = "good";
} else {
quality = "bad";
}
// Creare l'oggetto di uscita
let data = {
id: element._id,
location: element.location,
quality: quality,
}
// Aggiungerlo all'array di uscita
result.push(data);
});
msg.payload = result;
console.log(msg.payload);
return msg;
Se il JSON in debug di questo nodo coincide nel formato con quello di test che abbiamo usato nel nodo template, possiamo collegare i nodi di richiesta e risposta e concludere così il progetto.