Le blog tech de Nicolas Steinmetz (Time Series, IoT, Web, Ops, Data)
Le projet Nous Aérons propose de réaliser ses propres détecteurs de CO2 avec un ESP32 avec un écran comme le Lilygo TTGo T-Display et un capteur Senseair S8-LP.
L’idée est donc de déployer plusieurs capteurs, faire remonter les valeurs via ThingSpeak et ensuite les ingérer puis analyser avec Warp 10 et faire un dashboard avec Discovery.
Pour le montage, je vous invite à consuler principalement :
/dev/cu.wchusbserial*
afin de pouvoir uploader le code depuis Arduino IDE vers l’ESP32.L’exemple de code fourni utilise le service ThingSpeak pour la remontée des valeurs. Comme il s’agit de mon premier projet Arduino et que cela fonctionne, j’ai cherché à rester dans les clous du code proposé et tester par la même occasion ce service. J’aurais pu directement poster les valeurs sur mon instance Warp 10 mais c’est aussi l’occasion de tester la récupération d’informations via le client MQTT de Warp 10.
Il vous faut :
Disclaimer : c’est mon premier projet Arduino.
En repartant du code fourni sur le site Capteur de CO2, j’ai fait quelques ajustements :
200
si c’est OK, 40x
si incorrect et -XXX
si erreur ; j’ai un amélioré le message de debug pour savoir si l’insertion était OK ou KO.Il vous faut modifier :
ssid1
, password1
et éventuellement ssid2
, password2
channelID
, writeAPIKey
Compiler le tout et uploader le code sur votre ESP32.
/************************************************
*
* Capteur de CO2 par Grégoire Rinolfi
* https://co2.rinolfi.ch
*
***********************************************/
#include <TFT_eSPI.h>
#include <SPI.h>
#include <Wire.h>
#include <WiFiMulti.h>
#include <ThingSpeak.h>
WiFiMulti wifiMulti;
TFT_eSPI tft = TFT_eSPI(135, 240);
/************************************************
*
* Paramètres utilisateur
*
***********************************************/
#define TXD2 21 // série capteur TX
#define RXD2 22 // série capteur RX
#define BOUTON_CAL 35
#define DEBOUNCE_TIME 1000
const char* ssid1 = "wifi1";
const char* password1 = "XXXXXXXXXXXXXXXX";
const char* ssid2 = "wifi2";
const char* password2 = "XXXXXXXXXXXXXXXX";
unsigned long channelID = XXXXXXXXXXXXXXXX;
char* readAPIKey = "XXXXXXXXXXXXXXXX";
char* writeAPIKey = "XXXXXXXXXXXXXXXX";
unsigned int dataFieldOne = 1; // Field to write temperature data
const unsigned long postingInterval = 12L * 1000L; // 12s
unsigned long lastTime = 0;
// gestion de l'horloge pour la validation des certificats HTTPS
void setClock() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
Serial.print(F("Waiting for NTP time sync: "));
time_t nowSecs = time(nullptr);
while (nowSecs < 8 * 3600 * 2) {
delay(500);
Serial.print(F("."));
yield();
nowSecs = time(nullptr);
}
Serial.println();
struct tm timeinfo;
gmtime_r(&nowSecs, &timeinfo);
Serial.print(F("Current time: "));
Serial.print(asctime(&timeinfo));
}
/************************************************
*
* Thinkgspeak functions
* https://fr.mathworks.com/help/thingspeak/read-and-post-temperature-data.html
*
***********************************************/
float readTSData( long TSChannel,unsigned int TSField ){
float data = ThingSpeak.readFloatField( TSChannel, TSField, readAPIKey );
Serial.println( " Data read from ThingSpeak: " + String( data, 9 ) );
return data;
}
// Use this function if you want to write a single field.
int writeTSData( long TSChannel, unsigned int TSField, float data ){
int writeSuccess = ThingSpeak.writeField( TSChannel, TSField, data, writeAPIKey ); // Write the data to the channel
if(writeSuccess == 200){
Serial.println("Channel updated successfully!");
}
else{
Serial.println("Problem updating channel. HTTP error code " + String(writeSuccess));
}
return writeSuccess;
}
// Use this function if you want to write multiple fields simultaneously.
int write2TSData( long TSChannel, unsigned int TSField1, long field1Data, unsigned int TSField2, long field2Data ){
ThingSpeak.setField( TSField1, field1Data );
ThingSpeak.setField( TSField2, field2Data );
int writeSuccess = ThingSpeak.writeFields( TSChannel, writeAPIKey );
if(writeSuccess == 200){
Serial.println("Channel updated successfully!");
}
else{
Serial.println("Problem updating channel. HTTP error code " + String(writeSuccess));
}
return writeSuccess;
}
/************************************************
*
* Code de gestion du capteur CO2 via ModBus
* inspiré de : https://github.com/SFeli/ESP32_S8
*
***********************************************/
volatile uint32_t DebounceTimer = 0;
byte CO2req[] = {0xFE, 0X04, 0X00, 0X03, 0X00, 0X01, 0XD5, 0XC5};
byte ABCreq[] = {0xFE, 0X03, 0X00, 0X1F, 0X00, 0X01, 0XA1, 0XC3};
byte disableABC[] = {0xFE, 0X06, 0X00, 0X1F, 0X00, 0X00, 0XAC, 0X03}; // écrit la période 0 dans le registre HR32 à adresse 0x001f
byte enableABC[] = {0xFE, 0X06, 0X00, 0X1F, 0X00, 0XB4, 0XAC, 0X74}; // écrit la période 180
byte clearHR1[] = {0xFE, 0X06, 0X00, 0X00, 0X00, 0X00, 0X9D, 0XC5}; // ecrit 0 dans le registe HR1 adresse 0x00
byte HR1req[] = {0xFE, 0X03, 0X00, 0X00, 0X00, 0X01, 0X90, 0X05}; // lit le registre HR1 (vérifier bit 5 = 1 )
byte calReq[] = {0xFE, 0X06, 0X00, 0X01, 0X7C, 0X06, 0X6C, 0XC7}; // commence la calibration background
byte Response[20];
uint16_t crc_02;
int ASCII_WERT;
int int01, int02, int03;
unsigned long ReadCRC; // CRC Control Return Code
void send_Request (byte * Request, int Re_len)
{
while (!Serial1.available())
{
Serial1.write(Request, Re_len); // Send request to S8-Sensor
delay(50);
}
Serial.print("Requete : ");
for (int02 = 0; int02 < Re_len; int02++) // Empfangsbytes
{
Serial.print(Request[int02],HEX);
Serial.print(" ");
}
Serial.println();
}
void read_Response (int RS_len)
{
int01 = 0;
while (Serial1.available() < 7 )
{
int01++;
if (int01 > 10)
{
while (Serial1.available())
Serial1.read();
break;
}
delay(50);
}
Serial.print("Reponse : ");
for (int02 = 0; int02 < RS_len; int02++) // Empfangsbytes
{
Response[int02] = Serial1.read();
Serial.print(Response[int02],HEX);
Serial.print(" ");
}
Serial.println();
}
unsigned short int ModBus_CRC(unsigned char * buf, int len)
{
unsigned short int crc = 0xFFFF;
for (int pos = 0; pos < len; pos++) {
crc ^= (unsigned short int)buf[pos]; // XOR byte into least sig. byte of crc
for (int i = 8; i != 0; i--) { // Loop over each bit
if ((crc & 0x0001) != 0) { // If the LSB is set
crc >>= 1; // Shift right and XOR 0xA001
crc ^= 0xA001;
}
else // else LSB is not set
crc >>= 1; // Just shift right
}
} // Note, this number has low and high bytes swapped, so use it accordingly (or swap bytes)
return crc;
}
unsigned long get_Value(int RS_len)
{
// Check the CRC //
ReadCRC = (uint16_t)Response[RS_len-1] * 256 + (uint16_t)Response[RS_len-2];
if (ModBus_CRC(Response, RS_len-2) == ReadCRC) {
// Read the Value //
unsigned long val = (uint16_t)Response[3] * 256 + (uint16_t)Response[4];
return val * 1; // S8 = 1. K-30 3% = 3, K-33 ICB = 10
}
else {
Serial.print("CRC Error");
return 99;
}
}
// interruption pour lire le bouton d'étalonnage
bool demandeEtalonnage = false;
void IRAM_ATTR etalonnage() {
if ( millis() - DEBOUNCE_TIME >= DebounceTimer ) {
DebounceTimer = millis();
Serial.println("Etalonnage manuel !!");
tft.fillScreen(TFT_BLACK);
tft.setTextSize(3);
tft.setTextColor(TFT_WHITE);
tft.drawString("Etalonnage", tft.width() / 2, tft.height()/2);
demandeEtalonnage = true;
}
}
// nettoie l'écran et affiche les infos utiles
void prepareEcran() {
tft.fillScreen(TFT_BLACK);
// texte co2 à gauche
tft.setTextSize(4);
tft.setTextColor(TFT_WHITE);
tft.drawString("CO",25, 120);
tft.setTextSize(3);
tft.drawString("2",60, 125);
// texte PPM à droite ppm
tft.drawString("ppm",215, 120);
// écriture du chiffre
tft.setTextColor(TFT_GREEN,TFT_BLACK);
tft.setTextSize(8);
}
void setup() {
// bouton de calibration
pinMode(BOUTON_CAL, INPUT);
// ports série de debug et de communication capteur
Serial.begin(115200);
Serial1.begin(9600, SERIAL_8N1, RXD2, TXD2);
// initialise l'écran
tft.init();
delay(20);
tft.setRotation(1);
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM); // imprime la string middle centre
// vérifie l'état de l'ABC
send_Request(ABCreq, 8);
read_Response(7);
Serial.print("Période ABC : ");
Serial.printf("%02ld", get_Value(7));
Serial.println();
int abc = get_Value(7);
// active ou désactive l'ABC au démarrage
if(digitalRead(BOUTON_CAL) == LOW){
if(abc == 0){
send_Request(enableABC, 8);
}else{
send_Request(disableABC, 8);
}
read_Response(7);
get_Value(7);
}
tft.setTextSize(2);
tft.setTextColor(TFT_BLUE,TFT_BLACK);
tft.drawString("Autocalibration", tft.width() / 2, 10);
if( abc != 0 ){
tft.drawString(String(abc)+"h", tft.width() / 2, 40);
}else{
tft.drawString("OFF", tft.width() / 2, 40);
}
// gestion du wifi
wifiMulti.addAP(ssid1, password1);
wifiMulti.addAP(ssid2, password2);
Serial.print("Connexion au wifi");
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE,TFT_BLACK);
tft.drawString("Recherche wifi", tft.width() / 2, tft.height() / 2);
int i = 0;
while(wifiMulti.run() != WL_CONNECTED && i < 3){
Serial.print(".");
delay(500);
i++;
}
if(wifiMulti.run() == WL_CONNECTED){
tft.setTextColor(TFT_GREEN,TFT_BLACK);
Serial.println("Connecté au wifi");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
tft.drawString("Wifi OK", tft.width() / 2, 100);
setClock();
}else{
tft.setTextColor(TFT_RED,TFT_BLACK);
Serial.println("Echec de la connexion wifi");
tft.drawString("Pas de wifi", tft.width() / 2, 100);
}
delay(3000); // laisse un temps pour lire les infos
// préparation de l'écran
prepareEcran();
//interruption de lecture du bouton
attachInterrupt(BOUTON_CAL, etalonnage, FALLING);
}
unsigned long ancienCO2 = 0;
int seuil = 0;
void loop() {
// effectue l'étalonnage si on a appuyé sur le bouton
if( demandeEtalonnage ){
demandeEtalonnage = false;
// nettoye le registre de verification
send_Request(clearHR1, 8);
read_Response(8);
delay(100);
// demande la calibration
send_Request(calReq, 8);
read_Response(8);
delay(4500); // attend selon le cycle de la lampe
// lit le registre de verification
send_Request(HR1req, 8);
read_Response(7);
int verif = get_Value(7);
Serial.println("resultat calibration "+String(verif));
if(verif == 32){
tft.setTextColor(TFT_GREEN);
tft.drawString("OK", tft.width() / 2, tft.height()/2+30);
}else{
tft.setTextColor(TFT_RED);
tft.drawString("Erreur", tft.width() / 2, tft.height()/2+20);
}
delay(3000);
prepareEcran();
seuil = 0;
}
// lecture du capteur
send_Request(CO2req, 8);
read_Response(7);
unsigned long CO2 = get_Value(7);
String CO2s = "CO2: " + String(CO2);
Serial.println(CO2s);
// efface le chiffre du texte
if(CO2 != ancienCO2){
tft.fillRect(0,0, tft.width(), 60, TFT_BLACK);
}
if( CO2 < 800 ){
tft.setTextColor(TFT_GREEN,TFT_BLACK);
if( seuil != 1 ){
tft.setTextSize(2);
tft.fillRect(0,61, tft.width(), 25, TFT_BLACK);
tft.drawString("Air Excellent", tft.width() / 2, tft.height() / 2 + 10);
}
seuil = 1;
}else if( CO2 >= 800 && CO2 < 1000){
tft.setTextColor(TFT_ORANGE,TFT_BLACK);
if( seuil != 2 ){
tft.setTextSize(2);
tft.fillRect(0,61, tft.width(), 25, TFT_BLACK);
tft.drawString("Air Moyen", tft.width() / 2, tft.height() / 2 + 10);
}
seuil = 2;
}else if (CO2 >= 1000 && CO2 < 1500){
tft.setTextColor(TFT_RED,TFT_BLACK);
if( seuil != 3 ){
tft.setTextSize(2);
tft.fillRect(0,61, tft.width(), 25, TFT_BLACK);
tft.drawString("Air Mediocre", tft.width() / 2, tft.height() / 2 + 10);
}
seuil = 3;
}else{
tft.setTextColor(TFT_RED,TFT_BLACK);
if( seuil != 4 ){
tft.setTextSize(2);
tft.fillRect(0,61, tft.width(), 25, TFT_BLACK);
tft.drawString("Air Vicie", tft.width() / 2, tft.height() / 2 + 10);
}
seuil = 4;
}
tft.setTextSize(8);
tft.drawString(String(CO2), tft.width() / 2, tft.height() / 2 - 30);
// envoi de la valeur sur le cloud
if((millis() - lastTime) >= postingInterval) {
if((wifiMulti.run() == WL_CONNECTED)) {
WiFiClient client;
ThingSpeak.begin( client );
writeTSData( channelID , dataFieldOne , CO2 );
lastTime = millis();
}
}
ancienCO2 = CO2;
delay(10000); // attend 10 secondes avant la prochaine mesure
}
Sur ThingSpeak, aller dans Devices > MQTT et compléter si besoin avec la lecture de la documentation MQTT Basics:
Sur l’instance Warp 10, déployer le plugin MQTT :
Avec le script /path/to/warp10/mqtt/test.mc2
:
// subscribe to the topics, attach a WarpScript™ macro callback to each message
// the macro reads ThingSpeak message to extract the first byte of payload,
// the server timestamp, the channel id and the value
'Loading MQTT ThingSpeak Air Quality Warpscript™' STDOUT
{
'host' 'mqtt3.thingspeak.com'
'port' 1883
'user' 'XXXXXXXXXX'
'password' 'XXXXXXXXXX'
'clientid' 'XXXXXXXXXX'
'topics' [
'channels/channelID 1/subscribe'
'channels/channelID 2/subscribe'
'channels/channelID 3/subscribe'
]
'timeout' 20000
'parallelism' 1
'autoack' true
'macro'
<%
//in case of timeout, the macro is called to flush buffers, if any, with NULL on the stack.
'message' STORE
<% $message ISNULL ! %>
<%
// message structure :
// {elevation=null, latitude=null, created_at=2022-01-11T10:02:27Z, field1=412.00000, field7=null, field6=null, field8=null, field3=null, channel_id=1630275, entry_id=417, field2=null, field5=null, field4=null, longitude=null, status=null}
$message MQTTPAYLOAD 'ascii' BYTES-> JSON-> 'TSmessage' STORE
$TSmessage 'created_at' GET TOTIMESTAMP 'ts' STORE
$TSmessage 'channel_id' GET 'channelId' STORE
$TSmessage 'field1' GET 'sensorValue' STORE
$message MQTTTOPIC ' ' +
$ts ISO8601 + ' ' +
$channelId TOSTRING + ' ' +
$sensorValue +
STDOUT // print to warp10.log
%> IFT
%>
}
Vous devriez avoir dans /path/to/warp10/log/warp10.log
:
Loading MQTT ThingSpeak Air Quality Warpscript™
channels/<channelID 1>/subscribe 2022-01-11T10:30:51.000000Z <channelID 1> 820.00000
channels/<channelID 2>/subscribe 2022-01-11T10:30:53.000000Z <channelID 2> 715.00000
channels/<channelID 3>/subscribe 2022-01-11T10:30:54.000000Z <channelID 3> 410.00000
Maintenant que l’intégration MQTT est validée, supprimez ce fichier et passons à la gestion de la persistence des données dans Warp 10.
Avec le script suivant :
// subscribe to the topics, attach a WarpScript™ macro callback to each message
// the macro reads ThingSpeak message to extract the first byte of payload,
// the server timestamp, the channel id and the value.
{
'host' 'mqtt3.thingspeak.com'
'port' 1883
'user' 'XXXXXXXXXX'
'password' 'XXXXXXXXXX'
'clientid' 'XXXXXXXXXX'
'topics' [
'channels/channelID 1/subscribe'
'channels/channelID 2/subscribe'
'channels/channelID 3/subscribe'
]
'timeout' 20000
'parallelism' 1
'autoack' true
'macro'
<%
//in case of timeout, the macro is called to flush buffers, if any, with NULL on the stack.
'message' STORE
<% $message ISNULL ! %>
<%
// message structure :
// {elevation=null, latitude=null, created_at=2022-01-11T10:02:27Z, field1=412.00000, field7=null, field6=null, field8=null, field3=null, channel_id=1630275, entry_id=417, field2=null, field5=null, field4=null, longitude=null, status=null}
$message MQTTPAYLOAD 'ascii' BYTES-> JSON-> 'TSmessage' STORE
$TSmessage 'created_at' GET TOTIMESTAMP 'ts' STORE
$TSmessage 'channel_id' GET 'channelId' STORE
$TSmessage 'field1' GET 'sensorValue' STORE
// Tableau de correspondance entre mes channel IDs et mes devices en vue de définir des labels pour les GTS
{
<channelID 1> 'air1'
<channelID 2> 'air2'
<channelID 3> 'air3'
} 'deviceMap' STORE
// Récupération du nom du device dans la variable senssorId
$deviceMap $channelId GET 'sensorId' STORE
// Création d'une GTS air.quality.home
// Le label "device" aura pour valeur le nom du device, via la variable sensorId
// On crée une entrée qui correspond à la valeur que nous venons de récupérer
// sensorValue est une string, il faut la repasser sur un format numérique
// Une fois la GTS reconstituée avec son entrée, on la periste en base via UPDATE
'<writeToken>' 'writeToken' STORE
NEWGTS 'air.quality.home' RENAME
{ 'device' $sensorId } RELABEL
$ts NaN NaN NaN $sensorValue TODOUBLE TOLONG ADDVALUE
$writeToken UPDATE
%> IFT
%>
}
Depuis le WarpStudio, vérifiez la disposnibilité de vos données :
'<readToken>' 'readToken' STORE
[ $readToken 'air.quality.home' {} NOW -1000 ] FETCH
Ensuite, il nous reste plus qu’à faire une petite macro et un dashboard pour présenter les données.
Pour la macro :
<%
{
'name' 'cerenit/iot/co2'
'desc' 'Provide CO2 levels per device'
'sig' [ [ [ [ 'device:STRING' ] ] [ 'result:GTS' ] ] ]
'params' {
'device' 'String'
'result' 'GTS'
}
'examples' [
<'
air1 @cerenit/iot/co2
'>
]
} INFO
// Actual code
SAVE 'context' STORE
'device' STORE // Save parameter as year
'<readToken>' 'readToken' STORE
[ $readToken 'air.quality.home' { 'device' $device } MAXLONG MINLONG ] FETCH
0 GET
$context RESTORE
%>
'macro' STORE
$macro
Et pour le dashboard Discovery :
<%
{
'title' 'Home CO2 Analysis'
'description' 'esp32 + Senseair S8 sensors at home'
'options' {
'scheme' 'CHARTANA'
}
'tiles' [
{
'title' 'Informations'
'type' 'display'
'w' 6 'h' 1 'x' 0 'y' 0
'data' {
'data' 'Détails et informations complémentaires : <a href="https://www.cerenit.fr/blog/air-quality-iot-esp32-senseair-thingspeak-mqtt-warp10-discovery/">IoT - Qualité de l air avec un esp32 (TTGo T-Display), le service ThingSpeak, du MQTT, Warp 10 et Discovery</a>'
}
}
{
'title' 'Device AIR1'
'type' 'line'
'w' 6 'h' 2 'x' 0 'y' 1
'macro' <% 'air1' @cerenit/macros/co2 %>
'options' {
'thresholds' [
{ 'value' 400 'color' '#008000' }
{ 'value' 600 'color' '#329932' }
{ 'value' 800 'color' '#66b266' }
{ 'value' 960 'color' '#ffdb99' }
{ 'value' 1210 'color' '#ffa500' }
{ 'value' 1760 'color' '#ff0000' }
]
}
}
{
'title' 'Device AIR2'
'type' 'line'
'w' 6 'h' 2 'x' 6 'y' 1
'macro' <% 'air2' @cerenit/macros/co2 %>
'options' {
'thresholds' [
{ 'value' 400 'color' '#008000' }
{ 'value' 600 'color' '#329932' }
{ 'value' 800 'color' '#66b266' }
{ 'value' 960 'color' '#ffdb99' }
{ 'value' 1210 'color' '#ffa500' }
{ 'value' 1760 'color' '#ff0000' }
]
}
}
{
'title' 'Device AIR3'
'type' 'line'
'w' 6 'h' 2 'x' 0 'y' 3
'macro' <% 'air3' @cerenit/macros/co2 %>
'options' {
'thresholds' [
{ 'value' 400 'color' '#008000' }
{ 'value' 600 'color' '#329932' }
{ 'value' 800 'color' '#66b266' }
{ 'value' 960 'color' '#ffdb99' }
{ 'value' 1210 'color' '#ffa500' }
{ 'value' 1760 'color' '#ff0000' }
]
}
}
]
}
{ 'url' 'https://w.ts.cerenit.fr/api/v0/exec' }
@senx/discovery2/render
%>
Le résultat est alors :
Bilan de ce que nous avons vu :
L’ensemble des fichiers peuvent être récupérés depuis cerenit/iot-air-quality.
compose
devrait devenir une sous-commande officiel de la CLI Docker ; on pourra alors faire docker compose up -d
jq
pour les données relationelles. Du SQL ou des fichiers Excel/CSV/JOSN/XML en entrée et les mêmes formats en sortie (et un peu plus).vector top
, la source internal_logs
et l’API GraphQL. Un guide de mise à jour vers la nouvelle syntaxe est disponible.