L’objectif est déployer un ESP32 dans le cloud de Google et de découvrir les différents services pour exploiter les données reccueillies.
Google Cloud Platform (GCP) est une plateforme de cloud computing fournie par Google.
Cloud IoT Core est un service pour gérer des données issues d’appareils répartis partout dans le monde. Associé à d’autres services de la plate-forme Cloud IoT, Cloud IoT Core constitue une solution complète pour collecter, traiter, analyser et visualiser des données IoT en temps réel.
Gestionnaire d’appareils : Le gestionnaire d’appareils permet de configurer et gérer les appareils individuellement. Le gestionnaire établit l’identité des appareils et fournit le mécanisme permettant de les authentifier lors de la connexion.
Passerelle de protocole : Elle offre une compatibilité native pour les connexions sécurisées via les protocoles standards tels que MQTT et HTTP. Cette passerelle publie dans Cloud Pub/Sub les données de télémétrie de tous les appareils, qui peuvent ensuite être exploitées par les systèmes d’analyse en aval.
Sécurité : Cloud IoT Core utilise une authentification par clés asymétriques via le protocole TLS 1.2.
Exemple :
Il est possible d’utiliser gratuitement les produits Google Cloud Platform jusqu’aux limites spécifiées :
L’essai gratuit comprend un crédit de 300 $ valable pendant 12 mois qui permet de payer les ressources utilisées au cours de l’apprentissage de Google Cloud.
L’utilisation des services de Google Cloud Platform peut se faire à partir de la console web ou en mode CLI avec Google SDK Cloud.
Le SDK Cloud est un ensemble d’outils en ligne de commande conçu pour développer et interagir avec Google Cloud. Ces outils peuvent servir à accéder à Compute Engine, Cloud Storage, BigQuery ainsi qu’à d’autres produits et services Google Cloud, directement à partir de la ligne de commande.
Lien : SDK Cloud
→ Installation du SDK Google Cloud : Debian et Ubuntu
Ici, on pourra utiliser l’image Google Cloud SDK à partir de docker
:
$ docker pull google/cloud-sdk:latest
$ docker run -ti google/cloud-sdk:latest gcloud version
$ docker run -ti -e CLOUDSDK_CONFIG=/config/mygcloud -v /home/tv/mygcloud/config:/config/mygcloud -v /home/tv/mygcloud:/certs google/cloud-sdk:latest /bin/bash
Il faut tout d’abord s’authentifier :
root@7d58a5a1f233:/# gcloud auth login
...
You are now logged in as [thierry.vaira@gmail.com].
root@7d58a5a1f233:/# gcloud auth list
Credentialed Accounts
ACTIVE ACCOUNT
* thierry.vaira@gmail.com
root@7d58a5a1f233:/# gcloud config list
[core]
account = thierry.vaira@gmail.com
disable_usage_reporting = True
Your active configuration is: [default]
root@7d58a5a1f233:/# gcloud projects list
PROJECT_ID NAME PROJECT_NUMBER
On peut aussi utiliser Cloud Shell :
L’utilisation de Google Cloud Shell est gratuite.
Documentation : Google Cloud IoT Core
Remarque : Tous les services de Google Cloud Platform sont liés à un projet.
On commence donc par créer un nouveau projet :
root@7d58a5a1f233:/# gcloud projects create test-esp32-iot
root@7d58a5a1f233:/# gcloud config set project test-esp32-iot
root@7d58a5a1f233:/# gcloud projects list
PROJECT_ID NAME PROJECT_NUMBER
test-esp32-iot test-esp32-iot 580197265786
On vérifie la création du projet dans la console web :
Puis, on active le service Pub/Sub :
root@7d58a5a1f233:/# gcloud services enable pubsub.googleapis.com
Pour continuer, il faut activer l’API IoT Core et lui associer un compte de facturation :
La tarification est la suivante :
root@7d58a5a1f233:/# gcloud services enable cloudiot.googleapis.com
root@7d58a5a1f233:/# gcloud projects add-iam-policy-binding test-esp32-iot --member=serviceAccount:cloud-iot@system.gserviceaccount.com --role=roles/pubsub.publisher
Pour que les messages sur le Cloud IoT Core soient automatiquement transférés vers Pub/Sub, il faut créer un topic puis un abonnement :
root@7d58a5a1f233:/# gcloud pubsub topics create esp32-iot-capteurs
Created topic [projects/test-esp32-iot/topics/esp32-iot-capteurs].
root@7d58a5a1f233:/# gcloud pubsub subscriptions create esp32-iot-abonnement --topic esp32-iot-capteurs
Created subscription [projects/test-esp32-iot/subscriptions/esp32-iot-abonnement].
On vérifie les créations dans la console web :
Il est déjà possible de tester Pub/Sub en publiant un message sur le topic :
root@7d58a5a1f233:/# gcloud pubsub subscriptions pull --auto-ack esp32-iot-abonnement --limit=1
┌──────┬──────────────────┬────────────┬──────────────────┐
│ DATA │ MESSAGE_ID │ ATTRIBUTES │ DELIVERY_ATTEMPT │
├──────┼──────────────────┼────────────┼──────────────────┤
│ test │ 1196227930592791 │ │ │
└──────┴──────────────────┴────────────┴──────────────────┘
Il faut maintenant créer un registre d’appareils. Cela va permettre de regrouper un ensemble d’ appareils et de leur définir des propriétés communes (protocoles comme MQTT, HTTP, l’emplacement de stockage des données et les topics Pub/Sub, …) :
root@7d58a5a1f233:/# gcloud iot registries create mes-esp32-iot --region=europe-west1 --event-notification-config=topic=esp32-iot-capteurs --enable-mqtt-config --enable-http-config
Created registry [mes-esp32-iot].
On obtient :
Il est maintenant possible de créer un périphérique (ici l’ESP32) dans le registre d’appareils.
Les appareils s’authentifieront à partir d’un jeton JWT (Json Web Token). Cloud IoT Core utilise l’authentification par une paire de clés asymétriques et prend en charge les algorithmes RSA et Elliptic Curve : la clé privée est stockée sur l’appareil et la clé publique sur la plateforme Google.
Il faut tout d’abord générer des paires de clés :
root@7d58a5a1f233:/# openssl ecparam -genkey -name prime256v1 -noout -out ec_private.pem
root@7d58a5a1f233:/# openssl ec -in ec_private.pem -pubout -out ec_public.pem
root@7d58a5a1f233:/# ls -l *.pem
-rw------- 1 root root 227 Jun 12 05:37 ec_private.pem
-rw-r--r-- 1 root root 178 Jun 12 05:37 ec_public.pem
On crée un nouvel appareil à l’aide de la clé publique nouvellement générée :
root@7d58a5a1f233:/# gcloud iot devices create esp32-1 --region=europe-west1 --registry=mes-esp32-iot --public-key="path=./ec_public.pem,type=es256"
Created device [esp32-1].
L’appareil esp32-1
est bien créé :
Un JWT comprend trois parties :
L’en-tête et la charge utile sont des objets JSON, qui sont sérialisés en octets UTF-8, puis encodés en base64url. Au final, l’en-tête, la charge utile et la signature du JWT sont concaténés avec des points ‘.’. Le résultat constitue le JWT.
Liens : jwt.io et Using JSON Web Tokens
Exemple de création d’un JWT :
root@7d58a5a1f233:/# pip3 install pyjwt
Collecting pyjwt
Downloading PyJWT-1.7.1-py2.py3-none-any.whl (18 kB)
Installing collected packages: pyjwt
Successfully installed pyjwt-1.7.1
root@7d58a5a1f233:/# pip3 install cryptography
// Si besoin :
root@7d58a5a1f233:/# apt-get update
root@7d58a5a1f233:/# apt install vim
root@7d58a5a1f233:/# vim main.py
#!/usr/bin/env python
import datetime
import jwt
PROJECT_ID = 'test-esp32-iot'
PRIVATE_KEY_FILE = './ec_private.pem'
ALGORITHM = 'ES256'
jeton = {
'iat': datetime.datetime.utcnow(),
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=60),
'aud': PROJECT_ID
}
with open(PRIVATE_KEY_FILE, 'r') as fichier:
cle_privee = fichier.read()
print('JWT :')
print(jwt.encode(jeton, cle_privee, algorithm=ALGORITHM))
root@7d58a5a1f233:/# python3 main.py
JWT :
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1OTIwMjYxMDgsImV4cCI6MTU5MjAyOTcwOCwiYXVkIjoidGVzdC1lc3AzMi1pb3QifQ.QjGkkWh92iP5FNCdIzmIoflCsOr-XOj3cu16ge7d8HNp4C7CLhwtESG34gcKJ5U402aGnmPveLedF1rZIqvsSw'
Test HTTP : le JWT doit être inclus dans l’en-tête de chaque demande HTTP.
root@7d58a5a1f233:/# curl -X POST -H 'authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1OTIwMjY1MDYsImV4cCI6MTU5MjAzMDEwNiwiYXVkIjoidGVzdC1lc3AzMi1pb3QifQ.rjxqfhdxFNX402v33AF_U9xt-yEwWFvNiwU13MeTa5iGDJRlPPxDqprsuYfijfDB9yFfdI4OZS-2ztbd9SM6Fw' -H 'content-type: application/json' -H 'cache-control: no-cache' --data '{"binary_data": "aGVsbG8K"}' 'https://cloudiotdevice.googleapis.com/v1/projects/test-esp32-iot/locations/europe-west1/registries/mes-esp32-iot/devices/esp32-1:publishEvent'
root@7d58a5a1f233:/# gcloud pubsub subscriptions pull --auto-ack esp32-iot-abonnement --limit=1
┌───────┬──────────────────┬─────────────────────────────────────┬──────────────────┐
│ DATA │ MESSAGE_ID │ ATTRIBUTES │ DELIVERY_ATTEMPT │
├───────┼──────────────────┼─────────────────────────────────────┼──────────────────┤
│ hello │ 1287053429139632 │ deviceId=esp32-1 │ │
│ │ │ deviceNumId=2903960430253926 │ │
│ │ │ deviceRegistryId=mes-esp32-iot │ │
│ │ │ deviceRegistryLocation=europe-west1 │ │
│ │ │ projectId=test-esp32-iot │ │
│ │ │ subFolder= │ │
└───────┴──────────────────┴─────────────────────────────────────┴──────────────────┘
Test MQTT : le JWT doit être transmis dans le password lors d’une connexion en MQTT.
root@7d58a5a1f233:/# apt-get install mosquitto-clients
root@7d58a5a1f233:/# mosquitto_pub --host mqtt.googleapis.com --port 8883 --id projects/test-esp32-iot/locations/europe-west1/registries/mes-esp32-iot/devices/esp32-1 --username unused --pw "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1OTIwMjY1MDYsImV4cCI6MTU5MjAzMDEwNiwiYXVkIjoidGVzdC1lc3AzMi1pb3QifQ.rjxqfhdxFNX402v33AF_U9xt-yEwWFvNiwU13MeTa5iGDJRlPPxDqprsuYfijfDB9yFfdI4OZS-2ztbd9SM6Fw" --cafile ./roots.pem --tls-version tlsv1.2 --protocol-version mqttv311 --debug --qos 1 --topic /devices/esp32-1/events --message "Hello MQTT"
root@7d58a5a1f233:/# gcloud pubsub subscriptions pull --auto-ack esp32-iot-abonnement --limit=1
┌────────────┬──────────────────┬─────────────────────────────────────┬──────────────────┐
│ DATA │ MESSAGE_ID │ ATTRIBUTES │ DELIVERY_ATTEMPT │
├────────────┼──────────────────┼─────────────────────────────────────┼──────────────────┤
│ Hello MQTT │ 1287053647839009 │ deviceId=esp32-1 │ │
│ │ │ deviceNumId=2903960430253926 │ │
│ │ │ deviceRegistryId=mes-esp32-iot │ │
│ │ │ deviceRegistryLocation=europe-west1 │ │
│ │ │ projectId=test-esp32-iot │ │
│ │ │ subFolder= │ │
└────────────┴──────────────────┴─────────────────────────────────────┴──────────────────┘
L’ESP32 doit pouvoir transmettre les données de ces capteurs vers le pont MQTT de Google Cloud IoT Core. Pour cela, l’ESP32 aura besoin d’un client MQTT et de générer un JWT à partir d’une clé privée fournie.
Google Cloud IoT Core fournit le SDK Cloud IoT Device (des bibliothèques écrites en C) pour se connecter, provisionner et gérer des appareils avec Cloud IoT Core.
→ Exemple de projet : ESP-Google-IoT avec le framework ESP-IDF qui s’appuie sur freeRTOS.
Il est aussi possible d’utiliser Mongoose OS un framework IoT.
→ Exemple de projet : ESP32 Feather HUZZAH32 + Google IoT Core
De manière plus simple, il est aussi possible d’utiliser des bibliothèques pour le framework Arduino :
Google Cloud IoT JWT : pour générer le JWT pour l’authentification Google Cloud IoT Core
arduino-mqtt : un client MQTT
Pour les tests, on utilisera un ESP32 avec un Bluedot (BME2820 + TSL2591) :
Si on utilise PlatformIO, on ajouttera les bibliothèques suivantes Google Cloud IoT Core JWT
, rBase64
et MQTT
dans le fichier platformio.ini
:
[env:lolin32]
platform = espressif32
board = lolin32
framework = arduino
lib_deps =
ESP8266_SSD1306
Adafruit Unified Sensor
DHT sensor library
Adafruit TSL2591 Library
Adafruit BME280 Library
Google Cloud IoT Core JWT
rBase64
MQTT
Il faut inclure les fichiers d’en-têtes suivants :
#include <Arduino.h>
#include <Client.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <MQTT.h>
#include <CloudIoTCore.h>
#include <CloudIoTCoreMqtt.h>
#include "ca_crt.h"
On va avoir besoin de la clé privée :
root@7d58a5a1f233:/# openssl ec -in ec_private.pem -noout -text
Ensuite, on effectue les déclarations ci-dessous (notamment en recopiant la clé privée dans le tableau private_key_str
):
// Wifi
const char *ssid = "SSID";
const char *password = "cle_wifi";
// Cloud IoT
const char *project_id = "test-esp32-iot";
const char *location = "europe-west1";
const char *registry_id = "mes-esp32-iot";
const char *device_id = "esp32-1";
// NTP
const char* ntp_primary = "pool.ntp.org";
const char* ntp_secondary = "time.nist.gov";
const char* private_key_str =
"xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:"
"xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:"
"xx:xx";
const int jwt_exp_secs = 3600; // Maximum 24H (3600*24)
const int ex_num_topics = 0;
const char* ex_topics[ex_num_topics];
Client *netClient;
CloudIoTCoreDevice *device;
CloudIoTCoreMqtt *mqtt;
MQTTClient *mqttClient;
unsigned long iss = 0;
String jwt;
unsigned long lastMillis = 0;
Il faut aussi le certificat Google que l’on place dans le fichier ca_crt.h
:
// root certificate
static const uint8_t ca_crt[] PROGMEM = {
0x30, 0x82, 0x05, 0x5a, 0x30, 0x82, 0x03, 0x42, 0xa0, 0x03, 0x02, 0x01,
0x02, 0x02, 0x10, 0x6e, 0x47, 0xa9, 0xc5, 0x4b, 0x47, 0x0c, 0x0d, 0xec,
...
0x98, 0x68, 0x66, 0x5b, 0xf1, 0xc6, 0x63, 0x47, 0x55, 0x1c, 0xba, 0xa5,
0x08, 0x51, 0x75, 0xa6, 0x48, 0x25
};
size_t ca_crt_len = 1374;
Le code source qui permet l’envoi de données en MQTT vers le cloud :
...
String getJwt();
void setupWifi();
void connectWifi();
void publishTelemetry(String data);
void publishTelemetry(String subfolder, String data);
void connect();
void setupCloudIoT();
void messageReceived(String &topic, String &payload)
{
Serial.println("messageReceived <- " + topic + " - " + payload);
}
void setup()
{
Serial.begin(115200);
while (!Serial);
setupCloudIoT();
...
}
void loop()
{
mqttClient->loop();
delay(10); // <- fixes some issues with WiFi stability
if (!mqttClient->connected())
{
connect();
}
if (millis() - lastMillis > PERIODE_ENVOI)
{
lastMillis = millis();
// réaliser une acquisition des mesures
...
String payload = String("{\"timestamp\":") + time(nullptr) +
String(",\"temperature\":") + sonde.getTemperature() +
String(",\"humidite\":") + sonde.getHumidite() +
String("}");
publishTelemetry(payload);
Serial.println("publishTelemetry -> " + payload);
}
}
String getJwt()
{
iss = time(nullptr);
Serial.println("Refreshing JWT");
jwt = device->createJWT(iss, jwt_exp_secs);
return jwt;
}
void setupWifi()
{
WiFi.mode(WIFI_STA);
// WiFi.setSleep(false); // May help with disconnect? Seems to have been removed from WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
{
delay(100);
}
configTime(0, 0, ntp_primary, ntp_secondary);
while (time(nullptr) < 1510644967)
{
delay(10);
}
}
void connectWifi()
{
while (WiFi.status() != WL_CONNECTED)
{
//Serial.print(".");
delay(1000);
}
}
///////////////////////////////
// Orchestrates various methods from preceeding code.
///////////////////////////////
void publishTelemetry(String data)
{
mqtt->publishTelemetry(data);
}
void publishTelemetry(String subfolder, String data)
{
mqtt->publishTelemetry(subfolder, data);
}
void connect()
{
connectWifi();
mqtt->mqttConnect();
}
void setupCloudIoT()
{
device = new CloudIoTCoreDevice(project_id, location, registry_id, device_id, private_key_str);
setupWifi();
netClient = new WiFiClientSecure();
mqttClient = new MQTTClient(512);
mqttClient->setOptions(180, true, 1000); // keepAlive, cleanSession, timeout
mqtt = new CloudIoTCoreMqtt(mqttClient, netClient, device);
mqtt->startMQTT();
}
Remarque : la fonction messageReceived()
est une fonction de callback qui sera exécutée à chaque fois qu’un message sera publié sur le topic de l’appareil (nottament pour la mise à jour de la configuration ou la réception de commandes)
Code source : ESP32-Google-IoT-Core.zip
root@7d58a5a1f233:/# gcloud pubsub subscriptions pull --auto-ack esp32-iot-abonnement --limit=100
┌───────────────────┬──────────────────┬─────────────────────────────────────┬──────────────────┐
│ DATA │ MESSAGE_ID │ ATTRIBUTES │ DELIVERY_ATTEMPT │
├───────────────────┼──────────────────┼─────────────────────────────────────┼──────────────────┤
│ esp32-1-connected │ 1287403475555760 │ deviceId=esp32-1 │ │
│ │ │ deviceNumId=2903960430253926 │ │
│ │ │ deviceRegistryId=mes-esp32-iot │ │
│ │ │ deviceRegistryLocation=europe-west1 │ │
│ │ │ projectId=test-esp32-iot │ │
│ │ │ subFolder=events │ │
└───────────────────┴──────────────────┴─────────────────────────────────────┴──────────────────┘
root@7d58a5a1f233:/# gcloud pubsub subscriptions pull --auto-ack esp32-iot-abonnement --limit=100
┌───────────────────────────────────────────────────────────┬──────────────────┬─────────────────────────────────────┬──────────────────┐
│ DATA │ MESSAGE_ID │ ATTRIBUTES │ DELIVERY_ATTEMPT │
├───────────────────────────────────────────────────────────┼──────────────────┼─────────────────────────────────────┼──────────────────┤
│ {"timestamp":1592041880,"temperature":25.1,"humidite":54} │ 1287402243041588 │ deviceId=esp32-1 │ │
│ │ │ deviceNumId=2903960430253926 │ │
│ │ │ deviceRegistryId=mes-esp32-iot │ │
│ │ │ deviceRegistryLocation=europe-west1 │ │
│ │ │ projectId=test-esp32-iot │ │
│ │ │ subFolder= │ │
└───────────────────────────────────────────────────────────┴──────────────────┴─────────────────────────────────────┴──────────────────┘
Exemple de debug sur le moniteur série :
Connect with mqtt.2030.ltsapis.goog:8883
ClientId: projects/test-esp32-iot/locations/europe-west1/registries/mes-esp32-iot/devices/esp32-1
Waiting 60 seconds, retry will likely fail
Refreshing JWT
connected
Library connected!
publishTelemetry -> {"timestamp":1592897597,"temperature":26.6,"humidite":52}
publishTelemetry -> {"timestamp":1592897657,"temperature":26.7,"humidite":52}
...
Chaque appareil peut fournir ou utiliser différents types d’informations :
/devices/{device-id}/config
. En HTTP, l’appareil doit explicitement demander une nouvelle configuration.Exemple de debug sur le moniteur série :
messageReceived <- /devices/esp32-1/config - your-config-data
Télémétrie : les données collectées par l’appareil sont désignées par le terme télémétrie. La télémétrie est une donnée accessible en lecture seule concernant l’environnement, généralement collectée à l’aide de capteurs. Ici la température et l’humidité qui sont envoyées par l’appareil.
Commandes : les commandes sont des actions effectuées par un appareil. Exemples de commandes : allumer une led, tourner à 360 degrés vers la droite, exécuter le cycle d’autonettoyage, … En MQTT, l’appareil doit s’abonner au topic /devices/{device-id}/commands
pour recevoir des commandes.
Exemple de debug sur le moniteur série :
messageReceived <- /devices/esp32-1/commands - on
Vue de l’appareil dans la console web :
Voir aussi : OTA update flow for IoT devices using Google Cloud Build
Les données de l’ESP32 sont maintenant dans le cloud :
Il y a plusieurs moyens de les exploiter au sein du cloud Google avec par exemple : BigQuery, DataFlow, etc …
BigQuery est un système de stockage de données (sans serveur) dans le cloud de Google. Il est possible de créer des tables et d’effectuer des requêtes SQL.
BigQuery est un entrepôt de données (data warehouse). Un entrepôt peut contenir des datasets qui sont équivalents aux bases de données. Les datasets peuvent contenir des tables ou des vues.
Le service est gratuit jusqu’à 1 To de données analysées par mois et 10 Go de données stockées.
Lien : Tarification
Quelques fonctionnalités :
Création d’un dataset :
thierry_vaira@cloudshell:~ (test-esp32-iot)$ bq --location=EU mk capteurs_esp32_iot
Dataset 'test-esp32-iot:capteurs_esp32_iot' successfully created.
thierry_vaira@cloudshell:~ (test-esp32-iot)$ bq ls
datasetId
--------------------
capteurs_esp32_iot
thierry_vaira@cloudshell:~ (test-esp32-iot)$ bq mk --table capteurs_esp32_iot.mesures timestamp:TIMESTAMP,temperature:FLOAT,humidite:INT64
Table 'test-esp32-iot:capteurs_esp32_iot.mesures' successfully created.
thierry_vaira@cloudshell:~ (test-esp32-iot)$ bq show test-esp32-iot:capteurs_esp32_iot.mesures
Table test-esp32-iot:capteurs_esp32_iot.mesures
Last modified Schema Total Rows Total Bytes Expiration Time Partitioning Clustered Fields Labels
----------------- ------------------------- ------------ ------------- ------------ ------------------- ------------------ --------
14 Jun 09:54:45 |- timestamp: timestamp 0 0
|- temperature: float
|- humidite: integer
{
"creationTime": "1592121285015",
"etag": "iI7ycnk9k8NrypcEu+1qkQ==",
"id": "test-esp32-iot:capteurs_esp32_iot.mesures",
"kind": "bigquery#table",
"lastModifiedTime": "1592121285066",
"location": "EU",
"numBytes": "0",
"numLongTermBytes": "0",
"numRows": "0",
"schema": {
"fields": [
{
"name": "timestamp",
"type": "TIMESTAMP"
},
{
"name": "temperature",
"type": "FLOAT"
},
{
"name": "humidite",
"type": "INTEGER"
}
]
},
"selfLink": "https://bigquery.googleapis.com/bigquery/v2/projects/test-esp32-iot/datasets/capteurs_esp32_iot/tables/mesures",
"tableReference": {
"datasetId": "capteurs_esp32_iot",
"projectId": "test-esp32-iot",
"tableId": "mesures"
},
"type": "TABLE"
}
thierry_vaira@cloudshell:~ (test-esp32-iot)$ bq query 'SELECT * FROM capteurs_esp32_iot.mesures'
Waiting on bqjob_r25a4a5d7a470355c_00000172b1d87f10_1 ... (0s) Current status: DONE
On obtient :
Remarque : pour supprimer l’ensemble des données, il faudra utiliser la commande bq rm -r capteurs_esp32_iot
.
Il est possible de tester l’accès avec un script Python par exemple :
thierry_vaira@cloudshell:~ (test-esp32-iot)$ vim test-bigquery.py
from google.cloud import bigquery
def query_stackoverflow():
client = bigquery.Client()
query_job = client.query("""SELECT * FROM `capteurs_esp32_iot.mesures` ORDER BY timestamp DESC LIMIT 10""")
print("Test SQL")
results = query_job.result() # Waits for job to complete.
for row in results:
print("{} : {} - {}".format(row.timestamp, row.temperature, row.humidite))
if __name__ == '__main__':
query_stackoverflow()
thierry_vaira@cloudshell:~ (test-esp32-iot)$ python3 test-bigquery.py
Test SQL
2020-06-14 13:00:59+00:00 : 25.1 - 49
2020-06-14 12:59:59+00:00 : 25.1 - 49
2020-06-14 12:58:58+00:00 : 24.9 - 50
2020-06-14 12:57:58+00:00 : 24.8 - 50
2020-06-14 12:56:58+00:00 : 24.8 - 50
2020-06-14 12:55:58+00:00 : 24.8 - 50
2020-06-14 12:54:58+00:00 : 24.8 - 50
2020-06-14 12:53:58+00:00 : 24.8 - 50
2020-06-14 12:52:58+00:00 : 24.8 - 50
2020-06-14 12:51:58+00:00 : 24.8 - 50
Dataflow est une service de traitement des données par flux et par lot sans serveur. Il est possible par exemple de créer une tâche Dataflow pour récupérer les messages du topic Pub/sub et de les envoyer dans une table BigQuery ou un ficher de Cloud Storage.
Dans Pub/Sub, il faut sélectionner le topic puis créer une tâche Dataflow pour exporter les données vers une table BigQuery :
Ce modèle préproduit un pipeline de streaming qui lit les messages au format JSON d’un topic Pub/Sub, les transforme à l’aide d’une fonction JavaScript et les écrit dans une table BigQuery existante. Les messages Pub/Sub doivent être au format JSON. Par exemple, le message au format {"timestamp":1592041880,"temperature":25.1,"humidite":54}
sera inséré dans la table BigQuery avec les colonnes nommées “timestamp”, “temperature” et “humidite”. Un emplacement de sortie temporaire doit exister dans Cloud Storage pour y écrire les fichiers avant l’exécution du pipeline.
Attention : une tarification horaire s’applique pour l’exécution d’une tâche Dataflow.
Cloud Functions permet d’exécuter du code dans le cloud Google sans serveur.
La tarification est basée sur le temps d’exécution de la fonction. Le service est gratuit jusqu’à 2 millions d’appels par mois (avec 400 000 Go-seconde et de 200 000 GHz-seconde de temps de calcul, ainsi que de 5 Go de trafic Internet par mois).
Lien : Tarification
La fonction peut être écrite en Go, Node.js, Java ou Python. Elle doit être associée à un déclencheur (trigger) ici Cloud Pub/Sub. Elle va permettre de créer un lien vers un autre service du cloud Google.
Il faut activer le service :
gcloud services enable cloudfunctions.googleapis.com
Ensuite, on va créer une fonction codée en Python :
thierry_vaira@cloudshell:~ (test-esp32-iot)$ mkdir esp32_pubsub_to_bigquery
thierry_vaira@cloudshell:~ (test-esp32-iot)$ vim main.py
import base64
import json
from datetime import datetime
def esp32_pubsub_to_bigquery(event, context):
"""Triggered from a message on a Cloud Pub/Sub topic.
Args:
event (dict): Event payload.
context (google.cloud.functions.Context): Metadata for the event.
"""
print("""This Function was triggered by messageId {} published at {}""".format(context.event_id, context.timestamp))
if 'attributes' in event:
device_id = event['attributes']['deviceId']
print('Device Id : {}'.format(device_id))
if 'data' in event:
pubsub_message = base64.b64decode(event['data']).decode('utf-8')
iot_data = json.loads(pubsub_message)
horodatage = datetime.utcfromtimestamp(iot_data['timestamp']).strftime('%Y-%m-%dT%H:%M:%SZ')
print('Horodatage : {}'.format(horodatage))
print('Temperature : {}'.format(iot_data['temperature']))
print('Humidite : {}'.format(iot_data['humidite']))
print(pubsub_message)
Déploiement de la fonction dans le cloud :
thierry_vaira@cloudshell:~ (test-esp32-iot)$ gcloud functions deploy esp32_pubsub_to_bigquery --runtime python37 --trigger-topic esp32-iot-capteurs --region europe-west1
Deploying function (may take a while - up to 2 minutes)...done.
availableMemoryMb: 256
entryPoint: esp32_pubsub_to_bigquery
eventTrigger:
eventType: google.pubsub.topic.publish
failurePolicy: {}
resource: projects/test-esp32-iot/topics/esp32-iot-capteurs
service: pubsub.googleapis.com
ingressSettings: ALLOW_ALL
labels:
deployment-tool: cli-gcloud
name: projects/test-esp32-iot/locations/europe-west1/functions/esp32_pubsub_to_bigquery
runtime: python37
serviceAccountEmail: test-esp32-iot@appspot.gserviceaccount.com
sourceUploadUrl: https://storage.googleapis.com/gcf-upload-europe-west1-ce429b91-2e9d-414f-b699-dbdeabba8d79/4c3f1a10-9061-4c3d-876a-4b73c6a8c9e4.zip?GoogleAccessId=service-580197265786@gcf-admin-robot.iam.gserviceaccount.com&Expires=1592126704&Signature=...
status: ACTIVE
timeout: 60s
updateTime: '2020-06-14T08:55:46.043Z'
versionId: '3'
thierry_vaira@cloudshell:~ (test-esp32-iot)$ gcloud functions list
NAME STATUS TRIGGER REGION
esp32_pubsub_to_bigquery ACTIVE Event Trigger europe-west1
On peut vérifier la création de la fonction :
Test : on peut envoyer un message sur le topic Pub/Sub
thierry_vaira@cloudshell:~ (test-esp32-iot)$ gcloud pubsub topics publish esp32-iot-capteurs --message "{\"timestamp\":$(date +%s),\"temperature\":25.6,\"humidite\":40}" --attribute "deviceId=test"
thierry_vaira@cloudshell:~ (test-esp32-iot)$ gcloud functions logs read --limit 50 --region europe-west1
Test ESP32 :
On va maintenant utiliser la Cloud Function esp32_pubsub_to_bigquery
pour insérer les données du topic Pub/Sub dans la table BigQuery :
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ vim requirements.txt
google-cloud-bigquery==1.25.0
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ vim main.py
import base64
import json
from datetime import datetime
from google.cloud import bigquery
def esp32_pubsub_to_bigquery(event, context):
"""Triggered from a message on a Cloud Pub/Sub topic.
Args:
event (dict): Event payload.
context (google.cloud.functions.Context): Metadata for the event.
"""
if 'data' in event:
pubsub_message = base64.b64decode(event['data']).decode('utf-8')
print(pubsub_message)
iot_data = json.loads(pubsub_message)
client = bigquery.Client()
table_id = "test-esp32-iot.capteurs_esp32_iot.mesures"
table = client.get_table(table_id)
rows_to_insert = [(iot_data['timestamp'],iot_data['temperature'],iot_data['humidite'])]
errors = client.insert_rows(table, rows_to_insert)
if errors == []:
print("Donnees ajoutees")
On déploie :
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ gcloud functions deploy esp32_pubsub_to_bigquery --runtime python37 --trigger-topic esp32-iot-capteurs --region europe-west1
Liens : Google BigQuery API client library ou Python Client for Google BigQuery
Test :
thierry_vaira@cloudshell:~/esp32_pubsub_to_bigquery (test-esp32-iot)$ bq query 'SELECT * FROM capteurs_esp32_iot.mesures'
Waiting on bqjob_r3f82b4506c59d8cd_00000172b25ff491_1 ... (0s) Current status: DONE
+---------------------+-------------+----------+
| timestamp | temperature | humidite |
+---------------------+-------------+----------+
| 2020-06-14 10:26:42 | 24.7 | 52 |
| 2020-06-14 10:28:42 | 24.7 | 51 |
| 2020-06-14 10:25:42 | 24.7 | 53 |
| 2020-06-14 10:27:42 | 24.7 | 51 |
+---------------------+-------------+----------+
Google Data Studio transforme des données en tableaux de bord et rapports informatifs (à l’aide de graphiques, de filtres, des liens et des images cliquables, de textes, …).
Depuis 2017, Google Data Studio est gratuit pour tous.
Lien : 6 raisons d’utiliser Google Data Studio
Le connecteur BigQuery de Google Data Studio permet d’accéder aux données des tables BigQuery dans Google Data Studio.
Il faut accepter les conditions d’utilisation puis Ajouter des données au rapport (on sélectionne BigQuery) :
Il faut ensuite sélectionner la table :
Ensuite, on crée et paramètre son rapport …
Il est ausii possible d’ajouter d’autres sources de données :
Et de créer une requête personnalisée :
Exemple de rapport dynamique :
Google Kubernetes Engine est un environnement prêt à l’emploi pour le déploiement d’applications conteneurisées. Les applications Kubernetes sont des applications préemballées qui peuvent être déployées sur Google Kubernetes Engine en quelques clics. Ces application Kubernetes sont disponibles sur le Google Marketplace.
Il est possible d’installer les outils open source Telegraph, InfluxDB et Grafana à partir du Google Marketplace :
Présentation de la pile TICK (Telegraf, InfluxDB, Chronograf, Kapacitor) :
Telegraf est un agent serveur piloté par plug-in pour collecter et envoyer des mesures et des événements à partir de bases de données, de systèmes et de capteurs IoT.
InfluxDB est un système de gestion de base de données orientée séries chronologiques ou temporelles hautes performances et qui fournit un langage de requête de type SQL appelé InfluxQL pour interagir avec les données.
Chronograf est l’interface utilisateur et le composant administratif de la plate-forme InfluxDB. Il permet de créer des tableaux de bord avec des visualisations en temps réel.
Kapacitor est un moteur de traitement de données natif pour InfluxDB. Il peut traiter à la fois les données de flux et les données par lots à partir d’InfluxDB, en agissant sur ces données en temps réel via son langage de programmation TICKscript. En complément d’un tableau de bord, Kapacitor permet de déclencher des actions à partir d’alertes sur un modèle de publication-abonnement (pub/sub).
Grafana est un logiciel libre qui permet la visualisation de données. Il permet de réaliser des tableaux de bord et des graphiques depuis plusieurs sources dont des bases de données temporelles comme InfluxDB. L’outil offre aussi la possibilité de mettre en place des alertes.
Finalement, on va déployer seulement InfluxDB et Grafana manuellement.
On commence par créer un cluster :
Puis, on déploie InfluxDB et Grafana en utilisant les scripts fournis à cette adresse https://github.com/ThinkBigEg/influxDB-grafana-gke :
thierry_vaira@cloudshell:~ (test-esp32-iot)$ gcloud container clusters get-credentials cluster-1 --zone europe-west1-c --project test-esp32-iot
Fetching cluster endpoint and auth data.
kubeconfig entry generated for cluster-1.
thierry_vaira@cloudshell:~ (test-esp32-iot)$ git clone "https://github.com/ThinkBigEg/influxDB-grafana-gke"
Cloning into 'influxDB-grafana-gke'...
remote: Enumerating objects: 78, done.
remote: Total 78 (delta 0), reused 0 (delta 0), pack-reused 78
Unpacking objects: 100% (78/78), done.
thierry_vaira@cloudshell:~ (test-esp32-iot)$ cd influxDB-grafana-gke/configs/
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl create -f pv-claim.yaml
persistentvolumeclaim/pv-claim created
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl create -f influxdb.yaml
deployment.apps/influxdb created
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl create -f grafana.yaml
deployment.apps/grafana created
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl create -f influxdb-internal-service.yaml
service/influxdb-internal-service created
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl create -f influxdb-external-service.yaml
service/influxdb-external-service created
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ ^C
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl create -f grafana-service.yaml
service/grafana-external-service created
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl get pods
NAME READY STATUS RESTARTS AGE
grafana-77556c8c97-8nbx2 1/1 Running 0 3m18s
influxdb-596764bb57-8qz2t 1/1 Running 0 3m26s
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl get services influxdb-external-service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
influxdb-external-service LoadBalancer 10.0.4.129 35.195.198.8 8086:32524/TCP,8083:30520/TCP 6m18s
On vérifie dans la console web :
Création de la base de données InfluxDB :
Lien : Get started with InfluxDB
thierry_vaira@cloudshell:~/influxDB-grafana-gke/configs (test-esp32-iot)$ kubectl exec -it influxdb-596764bb57-8qz2t influx
kubectl exec [POD] [COMMAND] is DEPRECATED and will be removed in a future version. Use kubectl kubectl exec [POD] -- [COMMAND] instead.
Connected to http://localhost:8086 version 1.8.0
InfluxDB shell version: 1.8.0
> create database iot_esp32_db
> exit
Création de la fonction Python qui va insérer les données dans InfluxDB :
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ pip install influxdb
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ pip freeze | grep influxdb > requirements.txt
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ vim main.py
import base64
import json
from datetime import datetime
from influxdb import InfluxDBClient
def esp32_pubsub_to_influxdb(event, context):
"""Triggered from a message on a Cloud Pub/Sub topic.
Args:
event (dict): Event payload.
context (google.cloud.functions.Context): Metadata for the event.
"""
print("""This Function was triggered by messageId {} published at {}""".format(context.event_id, context.timestamp))
if 'attributes' in event:
device_id = event['attributes']['deviceId']
print('Device Id : {}'.format(device_id))
if 'data' in event:
pubsub_message = base64.b64decode(event['data']).decode('utf-8')
iot_data = json.loads(pubsub_message)
device_id = event['attributes']['deviceId']
horodatage = datetime.utcfromtimestamp(iot_data['timestamp']).strftime('%Y-%m-%dT%H:%M:%SZ')
print('Horodatage : {}'.format(horodatage))
print('Temperature : {}'.format(iot_data['temperature']))
print('Humidite : {}'.format(iot_data['humidite']))
print(pubsub_message)
host = '35.195.198.8'
port = 8086
username = ''
password = ''
database = 'iot_esp32_db'
client = InfluxDBClient(host, port, username, password, database)
json_data = [
{
"measurement": "temperature",
"time": horodatage,
"tags": {
'device': device_id
},
"fields": {
"value": iot_data['temperature']
}
}
]
result = client.write_points(json_data)
print("Result temperature : ", result)
json_data = [
{
"measurement": "humidite",
"time": horodatage,
"tags": {
'device': device_id
},
"fields": {
"value": iot_data['humidite']
}
}
]
result = client.write_points(json_data)
print("Result humidite : ", result)
Déploiement de la Cloud Function :
thierry_vaira@cloudshell:~/ (test-esp32-iot)$ gcloud functions deploy esp32_pubsub_to_influxdb --runtime python37 --trigger-topic esp32-iot-capteurs --region europe-west1
Vérification :
kubectl exec -it influxdb-596764bb57-8qz2t influx
Connected to http://localhost:8086 version 1.8.0
InfluxDB shell version: 1.8.0
> show DATABASES
name: databases
name
----
_internal
iot_esp32_db
> use iot_esp32_db
Using database iot_esp32_db
> select * from temperature
name: temperature
time device value
---- ------ -----
1592301848000000000 esp32-1 25
1592301908000000000 esp32-1 24.9
1592301968000000000 esp32-1 25
1592302028000000000 esp32-1 24.9
> select * from humidite
name: humidite
time device value
---- ------ -----
1592301848000000000 esp32-1 50
1592301908000000000 esp32-1 50
1592301968000000000 esp32-1 50
1592302028000000000 esp32-1 50
Dans Grafana, on se connecte en administrateur (admin/admin) :
On ajoute une source de données InfluxDB :
On paramètre en indiquant l’URL et la Database :
Et on crée un tableau de bord :
Résultat :
Le tableau de bord au format JSON (à importer) : grafana.json
Il faut utiliser un objet de la classe QNetworkAccessManager
pour émettre une requête GET ou POST avec un objet QNetworkRequest
. L’url sera stocké dans un objet de type QUrl
. On obtiendra la réponse à la requête par le signal finished(QNetworkReply*)
qui passera l’adresse d’un objet QNetworkReply
. On accédera au contenu de la réponse en appelant la méthode readAll()
.
Documentation : InfluxDB API reference
InfluxDB : [protocol]://[username:password@]host:port[/?db=database]
Test :
$ curl -G 'http://35.195.198.8:8086/query?pretty=true' --data-urlencode "db=iot_esp32_db" --data-urlencode "q=SELECT \"value\" FROM \"temperature\""
Exemple :
#include <QtWidgets>
#include <QNetworkAccessManager>
#include <QNetworkReply>
class IHM : public QWidget
{
Q_OBJECT
public:
IHM( QWidget *parent = 0 );
~IHM();
private:
QNetworkAccessManager *manager;
QNetworkReply *reply;
...
public slots:
void envoyerRequete();
void replyFinished(QNetworkReply *reply);
};
...
void IHM::envoyerRequete()
{
QString URL("http://35.195.198.8:8086/query");
QUrl url(URL);
QNetworkRequest request;
request.setUrl(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QUrlQuery postData;
//postData.addQueryItem("pretty", "true");
postData.addQueryItem("db", "iot_esp32_db");
postData.addQueryItem("q", "SELECT value FROM temperature");
qDebug() << Q_FUNC_INFO << request.url();
qDebug() << Q_FUNC_INFO << postData.toString();
manager->post(request, postData.toString(QUrl::FullyEncoded).toUtf8());
}
void IHM::replyFinished(QNetworkReply *reply)
{
QByteArray datas = reply->readAll();
qDebug() << Q_FUNC_INFO << datas;
...
}
Code source : test-qt-database-influxdb.zip
Il est possible d’effectuer des requêtes vers BigQuery à partir d’un script Google.
Par exemple dans Google Sheets :
Il faut activer l’API BigQuery :
Le script (voir bigquery.gs):
function onOpen() {
var spreadsheet = SpreadsheetApp.getActive();
var menuItems = [
{name: 'Températures', functionName: 'runQueryTemperature'},
{name: 'Humidités', functionName: 'runQueryHumidite'}
];
spreadsheet.addMenu('BigQuery', menuItems);
}
function runQueryTemperature() {
var projectId = 'test-esp32-iot';
var request = {
query: 'SELECT timestamp, temperature FROM capteurs_esp32_iot.mesures ORDER BY timestamp DESC LIMIT 10;'
};
var queryResults = BigQuery.Jobs.query(request, projectId);
var jobId = queryResults.jobReference.jobId;
var sleepTimeMs = 500;
while (!queryResults.jobComplete) {
Utilities.sleep(sleepTimeMs);
sleepTimeMs *= 2;
queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId);
}
var rows = queryResults.rows;
while (queryResults.pageToken) {
queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, {
pageToken: queryResults.pageToken
});
rows = rows.concat(queryResults.rows);
}
if (rows) {
var spreadsheet = SpreadsheetApp.getActive();
var sheet = spreadsheet.getSheetByName('Températures');
var timezone = spreadsheet.getSpreadsheetTimeZone();
// headers
var headers = queryResults.schema.fields.map(function(field) {
return field.name;
});
sheet.appendRow(headers);
// results
var data = new Array(rows.length);
for (var i = 0; i < rows.length; i++) {
var cols = rows[i].f;
data[i] = new Array(cols.length);
for (var j = 0; j < cols.length; j++) {
if(j == 0) { // timestamp
var d = new Date();
d.setTime(cols[j].v*1000);
var formattedDate = Utilities.formatDate(d, timezone, "dd/MM/yyyy HH:mm:ss");
data[i][j] = formattedDate;
}
else {
data[i][j] = cols[j].v;
}
}
}
sheet.getRange(2, 1, rows.length, headers.length).setValues(data);
Logger.log('Ok');
} else {
Logger.log('Aucun résultat');
}
}
function runQueryHumidite() {
var projectId = 'test-esp32-iot';
var request = {
query: 'SELECT timestamp, humidite FROM capteurs_esp32_iot.mesures ORDER BY timestamp DESC LIMIT 10;'
};
var queryResults = BigQuery.Jobs.query(request, projectId);
var jobId = queryResults.jobReference.jobId;
var sleepTimeMs = 500;
while (!queryResults.jobComplete) {
Utilities.sleep(sleepTimeMs);
sleepTimeMs *= 2;
queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId);
}
var rows = queryResults.rows;
while (queryResults.pageToken) {
queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, {
pageToken: queryResults.pageToken
});
rows = rows.concat(queryResults.rows);
}
if (rows) {
var spreadsheet = SpreadsheetApp.getActive();
var sheet = spreadsheet.getSheetByName('Humidités');
var timezone = spreadsheet.getSpreadsheetTimeZone();
// Append the headers.
var headers = queryResults.schema.fields.map(function(field) {
return field.name;
});
sheet.appendRow(headers);
// Append the results.
var data = new Array(rows.length);
for (var i = 0; i < rows.length; i++) {
var cols = rows[i].f;
data[i] = new Array(cols.length);
for (var j = 0; j < cols.length; j++) {
if(j == 0) { // timestamp
var d = new Date();
d.setTime(cols[j].v*1000);
var formattedDate = Utilities.formatDate(d, timezone, "dd/MM/yyyy HH:mm:ss");
data[i][j] = formattedDate;
}
else {
data[i][j] = cols[j].v;
}
}
}
sheet.getRange(2, 1, rows.length, headers.length).setValues(data);
Logger.log('Ok');
} else {
Logger.log('Aucun résultat');
}
}
Liens : Node-RED comes to GCP et Node-RED on Google Cloud Platform
Node-Red est utilisable sous forme de conteneur. Pour cela, il faut créer une instance pour le déployer :
Il faut ensuite créer une régle Pare-Feu pour autoriser le trafic entrant sur le port 1880. Après on se connecte sur http://<adresse_ip>:1880/
.
Dans Manage Palette
, il faut installer node-red-contrib-google-cloud :
On dispose maintenant de noeuds spécifiques à Google Cloud Platform :
Firebase est une plateforme de développement pour les applications web et mobile. Il fournit des APIs rassemblés dans un SDK unique.
Firebase propose un ensemble d’outils hébergés : Firebase Realtime Database (base de données NoSQL et en temps réel), Firebase Storage (upload et download de fichiers) et Cloud Firestore (du contenu), Firebase Authentication (authentification et gestion des utilisateurs), Firebase Remote Config (configuration à la volée de paramètres) et Firebase Cloud Messaging (des notifications et messages), ou encore Firebase Hosting (hébergement et déploiement d’applications) …
Le service a été racheté par Google en octobre 2014.
Lien : Documentation
On va ajouter une Cloud Function pour publier les données des capteurs vers une base de données Firebase Realtime Database et ensuite les exploiter à partir d’une application web en utilisant Firebase Hosting :
On va commencer par créer un projet dans Firebase en le liant au projet de Google Cloud Platform :
On valide les étapes suivantes :
La suite (création, gestion et déploiement du projet) va être réalisé en ligne de commande avec CLI Firebase.
Lien : Firebase CLI reference
Il faut tout d’abord l’installer :
$ npm install -g firebase-tools
...
+ firebase-tools@8.4.3
added 532 packages from 354 contributors in 17.866s
Et on s’authentifie :
$ firebase login
i Firebase optionally collects CLI usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.
? Allow Firebase to collect CLI usage and error reporting information? Yes
i To change your data collection preference at any time, run `firebase logout` and log in again.
Visit this URL on this device to log in:
https://accounts.google.com/o/oauth2/auth?client_id=563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com&scope=email%20openid%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloudplatformprojects.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Ffirebase%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform&response_type=code&state=468677606&redirect_uri=http%3A%2F%2Flocalhost%3A9005
Waiting for authentication...
✔ Success! Logged in as thierry.vaira@gmail.com
On vérifie la connexion en listant les projets :
$ firebase projects:list
✔ Preparing the list of your Firebase projects
┌──────────────────────┬───────────────────────────┬────────────────┬──────────────────────┐
│ Project Display Name │ Project ID │ Project Number │ Resource Location ID │
├──────────────────────┼───────────────────────────┼────────────────┼──────────────────────┤
│ myapplicationcamera │ myapplicationcamera-91f51 │ 521442060522 │ [Not specified] │
├──────────────────────┼───────────────────────────┼────────────────┼──────────────────────┤
│ test-esp32-iot │ test-esp32-iot │ 580197265786 │ [Not specified] │
└──────────────────────┴───────────────────────────┴────────────────┴──────────────────────┘
2 project(s) total.
Les nombreuses tâches que l’on va effectuer à l’aide de la CLI Firebase (comme le déploiement) nécessitent un répertoire de projet :
$ mkdir test-esp32-iot
$ cd test-esp32-iot/
Pour initialiser un nouveau projet Firebase, il faut exécuter la commande suivante à partir du répertoire de projet :
$ firebase init
######## #### ######## ######## ######## ### ###### ########
## ## ## ## ## ## ## ## ## ## ##
###### ## ######## ###### ######## ######### ###### ######
## ## ## ## ## ## ## ## ## ## ##
## #### ## ## ######## ######## ## ## ###### ########
You're about to initialize a Firebase project in this directory:
/home/tv/Téléchargements/test-esp32-iot
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. Database: Deploy Firebase Realtime D
atabase Rules, Functions: Configure and deploy Cloud Functions, Hosting: Configure and deploy Firebase Hosting sites
=== Project Setup
First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.
? Please select an option: Use an existing project
? Select a default Firebase project for this directory: test-esp32-iot (test-esp32-iot)
i Using project test-esp32-iot (test-esp32-iot)
=== Database Setup
Firebase Realtime Database Rules allow you to define how your data should be
structured and when your data can be read from and written to.
? What file should be used for Database Rules? database.rules.json
✔ Database Rules for test-esp32-iot have been downloaded to database.rules.json.
Future modifications to database.rules.json will update Database Rules when you run
firebase deploy.
=== Functions Setup
A functions directory will be created in your project with a Node.js
package pre-configured. Functions can be deployed with firebase deploy.
? What language would you like to use to write Cloud Functions? JavaScript
? Do you want to use ESLint to catch probable bugs and enforce style? No
✔ Wrote functions/package.json
✔ Wrote functions/index.js
✔ Wrote functions/.gitignore
? Do you want to install dependencies with npm now? Yes
> protobufjs@6.9.0 postinstall /home/tv/Téléchargements/test-esp32-iot/functions/node_modules/protobufjs
> node scripts/postinstall
npm notice created a lockfile as package-lock.json. You should commit this file.
added 252 packages from 206 contributors and audited 252 packages in 4.22s
29 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
=== Hosting Setup
Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.
? What do you want to use as your public directory? public
? Configure as a single-page app (rewrite all urls to /index.html)? No
✔ Wrote public/404.html
✔ Wrote public/index.html
i Writing configuration info to firebase.json...
i Writing project information to .firebaserc...
i Writing gitignore file to .gitignore...
✔ Firebase initialization complete!
On va commencer par implémenter la Cloud Function.
Liens : Get started et Firebase SDK for Cloud Functions Quickstart
$ vim functions/index.js
const functions = require('firebase-functions');
// https://firebase.google.com/docs/functions/write-firebase-functions
// The Firebase Admin SDK to access the Firebase Realtime Database:
const admin = require('firebase-admin');
admin.initializeApp();
exports.esp32_pubsub_to_firebase = functions.pubsub.topic('esp32-iot-capteurs').onPublish(
(message, context) => {
const deviceId = message.attributes.deviceId;
const timestamp = context.timestamp
const temperature = message.json.temperature;
const humidite = message.json.humidite;
console.log(`Device=${deviceId}, Timestamp=${timestamp} - Temperature=${temperature}°C - Humidite=${humidite}%`);
return admin.database().ref(`capteurs_esp32_iot/${deviceId}`).push({
timestamp: timestamp,
temperature: temperature,
humidite: humidite
})
});
On déploie la fonction :
$ firebase deploy --only functions
=== Deploying to 'test-esp32-iot'...
i deploying functions
i functions: ensuring required API cloudfunctions.googleapis.com is enabled...
✔ functions: required API cloudfunctions.googleapis.com is enabled
i functions: preparing functions directory for uploading...
i functions: packaged functions (27.46 KB) for uploading
✔ functions: functions folder uploaded successfully
i functions: creating Node.js 8 function esp32_pubsub_to_firebase(us-central1)...
✔ functions[esp32_pubsub_to_firebase(us-central1)]: Successful create operation.
✔ Deploy complete!
Project Console: https://console.firebase.google.com/project/test-esp32-iot/overview
On vérifie son fonctionnement en consultant les logs :
On déploie maintenant la base de données :
$ firebase deploy --only database
=== Deploying to 'test-esp32-iot'...
i deploying database
i database: checking rules syntax...
✔ database: rules syntax for database test-esp32-iot is valid
i database: releasing rules...
✔ database: rules for database test-esp32-iot released successfully
✔ Deploy complete!
Project Console: https://console.firebase.google.com/project/test-esp32-iot/overview
Si on souhaite gérer plusieurs ESP32, il est possible d’y ajouter leurs identifiants :
Remarque : Il est possible de définir les droits d’accès à la base de données dans le fichier database.rules.json
, ici on va autoriser l’accès en lecture :
{
/* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */
"rules": {
"devices_id": {
".read": true,
".write": false
},
"capteurs_esp32_iot": {
".read": true,
".write": false
}
}
}
On va maintenant développer l’application web dans le répertoire public
. L’application s’appuie sur les ressources suivantes :
$ vim public/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 IoT</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-material-design@4.1.1/dist/css/bootstrap-material-design.min.css" integrity="sha384-wXznGJNEXNG1NFsbm0ugrLFMQPWswR3lds2VeinahP8N0zJw9VWSopbjv2x7WCvX" crossorigin="anonymous">
<!-- update the version number as needed -->
<script defer src="/__/firebase/7.15.1/firebase-app.js"></script>
<!-- include only the Firebase features as you need -->
<script defer src="/__/firebase/7.15.1/firebase-auth.js"></script>
<script defer src="/__/firebase/7.15.1/firebase-database.js"></script>
<script defer src="/__/firebase/7.15.1/firebase-messaging.js"></script>
<script defer src="/__/firebase/7.15.1/firebase-storage.js"></script>
<!-- initialize the SDK after all desired features are loaded -->
<script defer src="/__/firebase/init.js"></script>
<!-- Include Plotly.js -->
<script defer src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<!-- Include the moment.js library -->
<script defer src="https://momentjs.com/downloads/moment.js"></script>
<script defer src="script.js"></script>
<style media="screen">
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
@media (max-width: 600px) {
body, #message { margin-top: 0; background: white; box-shadow: none; }
body { border-top: 16px solid #ffa100; }
}
.card { margin: 0 auto; float: none; margin-bottom: 10px; margin-top: 50px }
</style>
</head>
<body>
<div class="row">
<div class="col-sm-6">
<div class="card text-white bg-warning mb-3" style="max-width: 18rem;">
<div style="font-size: 20px;" class="card-header">Température</div>
<div class="card-body">
<h1 id="temperatureValue" style="font-size: 75px;" class="card-title">°C</h1>
<p id="temperatureHorodatage" class="card-text"></p>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="card text-white bg-primary mb-3" style="max-width: 18rem;">
<div style="font-size: 20px;" class="card-header">Humidité</div>
<div class="card-body">
<h1 id="humiditeValue" style="font-size: 75px;" class="card-title"><span>%</span></h1>
<p id="humiditeHorodatage" class="card-text"></p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="card text-white bg-warning mb-3" style="max-width: 45vw;">
<div id="temperaturePlot" style="max-height:55vh"></div>
</div>
</div>
<div class="col-sm-6">
<div class="card text-white bg-primary mb-3" style="max-width: 45vw;">
<div id="humiditePlot" style="max-height:55vh"></div>
</div>
</div>
</div>
<p style="text-align: center; margin: 0">
<a target="_blank" href="http://tvaira.free.fr/esp32/esp32-google-iot.html">GCP Cloud IoT et ESP32</a>
</p>
<!-- Needed for Bootstrap Material Design -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://unpkg.com/popper.js@1.12.6/dist/umd/popper.js" integrity="sha384-fA23ZRQ3G/J53mElWqVJEGJzU0sTs+SvzG8fXVWP+kJQ1lwFAOkcUOysnlKJC33U" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-material-design@4.1.1/dist/js/bootstrap-material-design.js" integrity="sha384-CauSuKpEqAFajSpkdjv3z9t8E7RlpJ1UP0lKM/+NdtSarroVKu069AlsRPKkFBz9" crossorigin="anonymous"></script>
</body>
</html>
$ vim public/script.js
// A web app that lively plots data from Firebase Realtime Database nodes, thanks to plotly.js
// cf. https://github.com/olivierlourme/iot-store-display
// Number of last records to display:
const nbOfElts = 60*24;
// Declaration of an array of devices ids
let devicesIds = [];
// Declaration of an array of devices aliases
let devicesAliases = [];
// Get references to the DOM node that welcomes the plots drawn by Plotly.js
const temperaturePlotDiv = document.getElementById('temperaturePlot');
const humiditePlotDiv = document.getElementById('humiditePlot');
const temperatureElement = document.getElementById('temperatureValue');
const humiditeElement = document.getElementById('humiditeValue');
const temperatureHorodatage = document.getElementById('temperatureHorodatage');
const humiditeHorodatage = document.getElementById('humiditeHorodatage');
// Get a reference to Firebase Realime Database :
const db = firebase.database();
// Display last value
db.ref(`capteurs_esp32_iot/esp32-1`).limitToLast(1).on('value', ts_measures => {
ts_measures.forEach(ts_measure => {
temperatureElement.innerText = ts_measure.val().temperature + '°C';
temperatureHorodatage.innerText = moment(ts_measure.val().timestamp).format('DD/MM/YYYY à HH:mm');
});
});
db.ref(`capteurs_esp32_iot/esp32-1`).limitToLast(1).on('value', ts_measures => {
ts_measures.forEach(ts_measure => {
humiditeElement.innerText = ts_measure.val().humidite + '%';
humiditeHorodatage.innerText = moment(ts_measure.val().timestamp).format('DD/MM/YYYY à HH:mm');
});
});
// Plot
let timestamps;
let temperatures;
let humidites;
// For temperature and humidity, the common plotly.js layout
const commonLayout = {
autosize: true,
titlefont: {
//family: 'Roboto',
size: 16,
color: '#000'
},
xaxis: {
linecolor: 'black',
linewidth: 2
},
yaxis: {
titlefont: {
//family: 'Courier New, monospace',
size: 14,
color: '#000'
},
linecolor: 'black',
linewidth: 2,
},
margin: {
r: 50,
b: 150,
pad: 0
}
};
// Specific layout aspects for temperature chart
let temperatureLayout = JSON.parse(JSON.stringify(commonLayout));
temperatureLayout.title = '<b>Température</b>';
temperatureLayout.yaxis.title = '<b>°C</b>';
temperatureLayout.yaxis.range = [0, 50];
// Specific layout aspects for humidity chart
let humidityLayout = JSON.parse(JSON.stringify(commonLayout));
humidityLayout.title = '<b>Humidité</b>';
humidityLayout.yaxis.title = '<b>%</b>';
humidityLayout.yaxis.range = [0, 100];
// Make ONCE an array of devices ids and devices aliases
db.ref('devices_id').once('value', (snapshot) => {
snapshot.forEach(childSnapshot => {
const childKey = childSnapshot.key;
devicesIds.push(childKey);
const childData = childSnapshot.val();
let deviceAlias;
if(childData == true) {
deviceAlias = childKey; // alias is 'esp32-1' for instance
} else {
deviceAlias = childData; // alias is 'salle 1' for instance
}
devicesAliases.push(deviceAlias);
});
//console.log(devicesAliases);
if (devicesIds.length != 0) {
// objects 1st property (an array) initialization...
timestamps = { [devicesIds[0]]: [] };
temperatures = { [devicesIds[0]]: [] };
humidites = { [devicesIds[0]]: [] };
// ...and the rest of properties (somme arrays) initialization
for (let i = 1; i < devicesIds.length; i++) {
timestamps[devicesIds[i]] = [];
temperatures[devicesIds[i]] = [];
humidites[devicesIds[i]] = [];
}
} else console.log('No device id was found.')
})
.then(() => { // We start building database nodes listeners only when we have devices ids.
for (let i = 0; i < devicesIds.length; i++) {
db.ref(`capteurs_esp32_iot/${devicesIds[i]}`).limitToLast(nbOfElts).on('value', ts_measures => {
//console.log(ts_measures.val());
// We reinitialize the arrays to welcome timestamps, temperatures and humidites values:
timestamps[devicesIds[i]] = [];
temperatures[devicesIds[i]] = [];
humidites[devicesIds[i]] = [];
ts_measures.forEach(ts_measure => {
timestamps[devicesIds[i]].push(moment(ts_measure.val().timestamp).format('DD/MM/YYYY HH:mm'));
temperatures[devicesIds[i]].push(ts_measure.val().temperature);
humidites[devicesIds[i]].push(ts_measure.val().humidite);
});
// plotly.js: See https://plot.ly/javascript/getting-started/
// Temperatures
let temperatureTraces = []; // array of plotly temperature traces (n devices => n traces)
for (let i = 0; i < devicesIds.length; i++) {
temperatureTraces[i] = {
x: timestamps[devicesIds[i]],
y: temperatures[devicesIds[i]],
//mode: 'lines+markers',
line: {shape: 'spline'},
connectgaps: true,
name: devicesAliases[i]
}
}
let temperatureData = []; // last plotly object to build
for (let i = 0; i < devicesIds.length; i++) {
temperatureData.push(temperatureTraces[i]);
}
Plotly.newPlot(temperaturePlotDiv, temperatureData, temperatureLayout, { responsive: true });
// humidites
let humidityTraces = []; // array of plotly humidity traces (n devices => n traces)
for (let i = 0; i < devicesIds.length; i++) {
humidityTraces[i] = {
x: timestamps[devicesIds[i]],
y: humidites[devicesIds[i]],
line: {shape: 'spline'},
connectgaps: true,
name: devicesAliases[i]
}
}
let humidityData = []; // last plotly object to build
for (let i = 0; i < devicesIds.length; i++) {
humidityData.push(humidityTraces[i]);
}
Plotly.newPlot(humiditePlotDiv, humidityData, humidityLayout, { responsive: true });
});
}
})
.catch(err => {
console.err('An error occured:', err);
});
Il est préférable de tester d’abord en local :
$ firebase serve --only hosting
i hosting: Serving hosting files from: public
✔ hosting: Local server: http://localhost:5000
On peut ensuite déployer :
$ firebase deploy --only hosting
=== Deploying to 'test-esp32-iot'...
i deploying hosting
i hosting[test-esp32-iot]: beginning deploy...
i hosting[test-esp32-iot]: found 3 files in public
✔ hosting[test-esp32-iot]: file upload complete
i hosting[test-esp32-iot]: finalizing version...
✔ hosting[test-esp32-iot]: version finalized
i hosting[test-esp32-iot]: releasing new version...
✔ hosting[test-esp32-iot]: release complete
✔ Deploy complete!
Project Console: https://console.firebase.google.com/project/test-esp32-iot/overview
Hosting URL: https://test-esp32-iot.web.app
On obtient :
Pour un coût :
Firebase dispose de deux API pouvant être utilisées avec des projets Qt, C ++ et QML :
On va utiliser l’API REST Firebase pour Qt, un wrapper autour de l’API REST Firebase.
Il suffit de copier les fichiers firebase.h
et firebase.cpp
dans son projet Qt. Il faut aussi ajouter le module network
dans le fichier de projhet .pro
:
TEMPLATE = app
TARGET = test-qt-firebase
QT += core gui network
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
HEADERS += ihm.h firebase.h
SOURCES += main.cpp ihm.cpp firebase.cpp
Il suffit d’instancier un objet Firebase
et de lui passer en arguments l’URL de la base de données et le point de terminaison REST dans la base de données :
Firebase *firebase = new Firebase("https://test-esp32-iot.firebaseio.com/", "capteurs_esp32_iot/esp32-1");
Ensuite, on appelle getValue()
pour récupérer les valeurs ou listenEvents()
pour obtenir les données lors d’un changement. La classe Firebase
fournit leux signaux eventResponseReady()
et eventDataChanged()
associés aux appels précédents et qu’il suffit de connecter à des slots.
Exemple :
#include "ihm.h"
#include <firebase.h>
/*
Voir aussi :
https://github.com/Sriep/Qt_Firebase_REST_API
http://piersshepperson.co.uk/programming/2017/06/26/firebase-database-rest-api-qt/
*/
IHM::IHM( QWidget *parent ) : QWidget( parent )
{
// les widgets
labelHost = new QLabel(this);
labelHost->setText(QString::fromUtf8("Host : "));
leHost = new QLineEdit(this);
leHost->setFixedWidth(350);
leHost->setText("https://test-esp32-iot.firebaseio.com/");
labelRequete = new QLabel(this);
labelRequete->setText(QString::fromUtf8("Requête : "));
leRequete = new QLineEdit(this);
leRequete->setText(QString::fromUtf8("capteurs_esp32_iot/esp32-1"));
leRequete->setFixedWidth(250);
bGet = new QPushButton(QString::fromUtf8("GET"), this);
bGet->setEnabled(false);
bListen = new QPushButton(QString::fromUtf8("LISTEN"), this);
bListen->setEnabled(false);
bClear = new QPushButton(QString::fromUtf8("EFFACER"), this);
bClear->setEnabled(false);
journal = new QTextEdit(this);
journal->setReadOnly(true);
// mise en place des layout
QHBoxLayout *hLayout0 = new QHBoxLayout;
QHBoxLayout *hLayout1 = new QHBoxLayout;
QHBoxLayout *hLayout2 = new QHBoxLayout;
QVBoxLayout *mainLayout = new QVBoxLayout;
hLayout0->addWidget(labelHost);
hLayout0->addWidget(leHost);
hLayout0->addWidget(labelRequete);
hLayout0->addWidget(leRequete);
hLayout0->addStretch(1);
hLayout1->addWidget(bGet);
hLayout1->addWidget(bListen);
hLayout1->addWidget(bClear);
hLayout1->addStretch();
hLayout2->addWidget(journal);
mainLayout->addLayout(hLayout0);
mainLayout->addLayout(hLayout1);
mainLayout->addLayout(hLayout2);
setLayout(mainLayout);
// connexion signal/slot
connect(leHost, SIGNAL(editingFinished()), this, SLOT(majHost()));
connect(leRequete, SIGNAL(editingFinished()), this, SLOT(majRequete()));
connect(bGet, SIGNAL(clicked()), this, SLOT(envoyerRequete()));
connect(bListen, SIGNAL(clicked()), this, SLOT(ecouterChangement()));
connect(bClear, SIGNAL(clicked()), this, SLOT(effacer()));
// init
majHost();
majRequete();
}
IHM::~IHM()
{
}
void IHM::majHost()
{
...
}
void IHM::majRequete()
{
...
}
void IHM::envoyerRequete()
{
Firebase *firebase = new Firebase(host, requete);
qDebug() << Q_FUNC_INFO << "URL : " << firebase->getPath();
firebase->getValue();
connect(firebase, SIGNAL(eventResponseReady(QByteArray)), this, SLOT(onResponseReady(QByteArray)));
}
void IHM::ecouterChangement()
{
Firebase *firebase = new Firebase(host, requete);
firebase->listenEvents();
connect(firebase, SIGNAL(eventDataChanged(QString)), this, SLOT(onDataChanged(QString)));
}
void IHM::effacer()
{
journal->clear();
}
void IHM::onResponseReady(QByteArray datas)
{
qDebug() << Q_FUNC_INFO << datas;
QString infos(datas);
journal->append(infos);
bClear->setEnabled(true);
}
void IHM::onDataChanged(QString datas)
{
qDebug() << Q_FUNC_INFO << datas;
QString infos(datas);
journal->append(infos);
bClear->setEnabled(true);
}
Code source : test-qt-firebase.zip
Lien : Add Firebase to your Android project.
On commence par ajouter l’application Android au projet :
On poursuit le processus étape par étape :
Ensuite, on télécharge le fichier google-services.json
que l’on copie dans le dossier app
du projet AndroidStudio :
On suit les consignes pour modifier les fichiers du projet :
Le fichier build.gradle
à la racine du projet :
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'
classpath 'com.google.gms:google-services:4.3.3'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
jcenter()
maven {
url 'https://www.jitpack.io'
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
Le fichier build.gradle
du dossier app
du projet :
apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services'
android {
compileSdkVersion 29
buildToolsVersion "29.0.3"
defaultConfig {
applicationId "com.example.myapplicationfirebase"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation 'com.google.firebase:firebase-database:19.3.0'
implementation 'pl.pawelkleczkowski.customgauge:CustomGauge:1.0.4'
implementation 'com.github.Pygmalion69:Gauge:1.5.2'
}
Remarque : L’API firebase-analytics
n’est pas utile ici pour utiliser firebase-database
.
Il est possible d’exécuter l’application pour vérifier l’installation :
Le layout de l’exemple :
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#424242"
tools:context=".MainActivity">
<TextView
android:id="@+id/textTemperature"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:text="Température"
android:textAlignment="center"
android:textSize="28sp"
android:textColor="#ffffff"
app:layout_constraintBottom_toTopOf="@+id/temperature"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<!-- #738ffe -> -9203714 et #424242 -> -12434878 -->
<de.nitri.gauge.Gauge
android:id="@+id/gauge"
android:layout_width="250dp"
android:layout_height="250dp"
android:layout_centerHorizontal="true"
android:layout_below="@+id/textTemperature"
app:initialValue="0"
app:maxValue="50"
app:minValue="0"
app:totalNicks="60"
app:valuePerNick="1"
app:needleColor="-1"
app:scaleColor="-9203714"
app:faceColor="-12434878"
app:lowerTextSize="38"
app:layout_constraintBottom_toTopOf="@+id/textHumidite"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textTemperature" />
<TextView
android:id="@+id/temperature"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textAlignment="center"
android:textColor="#738ffe"
android:textSize="38sp"
app:layout_constraintBottom_toTopOf="@+id/textHumidite"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textTemperature" />
<TextView
android:id="@+id/textHumidite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:text="Humidité"
android:textAlignment="center"
android:textSize="28sp"
android:textColor="#ffffff"
app:layout_constraintBottom_toTopOf="@+id/humidite"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/temperature"
app:layout_constraintVertical_bias="0.0" />
<pl.pawelkleczkowski.customgauge.CustomGauge
android:id="@+id/gaugeHumidite"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerHorizontal="true"
android:layout_below="@+id/textHumidite"
app:gaugePointStartColor="#d4e157"
app:gaugePointEndColor="#d4e157"
app:gaugeStartAngle="135"
app:gaugeStrokeCap="BUTT"
app:gaugeStrokeColor="#757575"
app:gaugeStrokeWidth="10dp"
app:gaugeStartValue="0"
app:gaugeEndValue="100"
app:gaugeSweepAngle="270"
app:gaugeDividerSize="1"
app:gaugeDividerColor="#ffffff"
app:gaugeDividerStep="10"
app:gaugeDividerDrawFirst="true"
app:gaugeDividerDrawLast="true"
app:layout_constraintBottom_toTopOf="@+id/horodatage"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textHumidite" />
<TextView
android:id="@+id/humidite"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textAlignment="center"
android:textColor="#d4e157"
android:textSize="40sp"
app:layout_constraintBottom_toTopOf="@+id/horodatage"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textHumidite" />
<TextView
android:id="@+id/horodatage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:text=""
android:textAlignment="center"
android:textSize="28sp"
android:textColor="#ffffff"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/humidite" />
</androidx.constraintlayout.widget.ConstraintLayout>
Ressources : CustomGauge et Gauge
On crée une classe Mesure
:
package com.example.myapplicationfirebase;
public class Mesure
{
public String timestamp;
public Double temperature;
public Double humidite;
public Mesure()
{
// Default constructor required for calls to DataSnapshot.getValue(Mesure.class)
}
public Mesure(String timestamp, Double temperature, Double humidite)
{
this.timestamp = timestamp;
this.temperature = temperature;
this.humidite = humidite;
}
public String toString()
{
return "Timestamp : " + timestamp + " Température : " + temperature + " Humidité : " + humidite;
}
}
Lien : Read and Write Data on Android
Le code source de l’activité principale :
package com.example.myapplicationfirebase;
import androidx.appcompat.app.AppCompatActivity;
import de.nitri.gauge.Gauge;
import pl.pawelkleczkowski.customgauge.CustomGauge;
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;
import com.google.firebase.database.ChildEventListener;
import com.google.firebase.database.DataSnapshot;
import com.google.firebase.database.DatabaseError;
import com.google.firebase.database.FirebaseDatabase;
import com.google.firebase.database.DatabaseReference;
import com.google.firebase.database.Query;
import com.google.firebase.database.ValueEventListener;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
public class MainActivity extends AppCompatActivity
{
private static final String TAG = "MainActivity";
private FirebaseDatabase database = null;
private DatabaseReference databaseRef;
private boolean chargement = false;
private TextView temperature;
private TextView humidite;
private TextView horodatage;
private CustomGauge gaugeTemperature;
private Gauge gauge;
private CustomGauge gaugeHumidite;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
temperature = (TextView) findViewById(R.id.temperature);
humidite = (TextView) findViewById(R.id.humidite);
horodatage = (TextView) findViewById(R.id.horodatage);
//gaugeTemperature = (CustomGauge) findViewById(R.id.gaugeTemperature);
gauge = (Gauge) findViewById(R.id.gauge);
gaugeHumidite = (CustomGauge) findViewById(R.id.gaugeHumidite);
database = FirebaseDatabase.getInstance();
//databaseRef = database.getReference("capteurs_esp32_iot/esp32-1");
databaseRef = database.getReference().child("capteurs_esp32_iot").child("esp32-1");
Query query = databaseRef.limitToLast(1);
query.addChildEventListener(new ChildEventListener()
{
@Override
public void onChildAdded(DataSnapshot dataSnapshot, String prevChildKey) {
Log.d(TAG, "dataSnapshot : " + dataSnapshot.toString());
Mesure mesure = dataSnapshot.getValue(Mesure.class);
Log.d(TAG, "Mesure : " + mesure.toString());
//temperature.setText(mesure.temperature.toString() + "°C");
humidite.setText(mesure.humidite.intValue() + " %");
horodatage.setText(getHorodatageFormate(mesure.timestamp));
Double temperature = mesure.temperature*10.;
//gaugeTemperature.setValue(temperature.intValue());
gauge.moveToValue(mesure.temperature.floatValue());
gauge.setLowerText(mesure.temperature.toString() + "°C");
gaugeHumidite.setValue(mesure.humidite.intValue());
}
@Override
public void onChildChanged(DataSnapshot dataSnapshot, String prevChildKey) {}
@Override
public void onChildRemoved(DataSnapshot dataSnapshot) {}
@Override
public void onChildMoved(DataSnapshot dataSnapshot, String prevChildKey) {}
@Override
public void onCancelled(DatabaseError databaseError) {}
});
/*databaseRef.addValueEventListener(new ValueEventListener()
{
@Override
public void onDataChange(DataSnapshot dataSnapshot)
{
if(!chargement)
{
Log.d(TAG, "Nb mesures : " + dataSnapshot.getChildrenCount());
Log.d(TAG, "dataSnapshot : " + dataSnapshot.toString());
for (DataSnapshot postSnapshot: dataSnapshot.getChildren())
{
Mesure mesure = postSnapshot.getValue(Mesure.class);
//Log.d(TAG, "Mesure : " + mesure.toString());
//temperature.setText(mesure.temperature.toString() + "°C");
humidite.setText(mesure.humidite.intValue() + " %");
horodatage.setText(getHorodatageFormate(mesure.timestamp));
//gaugeTemperature.setValue(mesure.temperature.intValue());
gauge.moveToValue(mesure.temperature.floatValue());
gauge.setLowerText(mesure.temperature.toString() + "°C");
gaugeHumidite.setValue(mesure.humidite.intValue());
}
chargement = true;
}
}
@Override
public void onCancelled(DatabaseError error)
{
Log.w(TAG, "Erreur !", error.toException());
}
});
databaseRef.addChildEventListener(new ChildEventListener()
{
@Override
public void onChildAdded(DataSnapshot dataSnapshot, String prevChildKey) {
//Log.w(TAG, "onChildAdded");
if(chargement)
{
Log.d(TAG, "Nb mesures : " + dataSnapshot.getChildrenCount());
Log.d(TAG, "dataSnapshot : " + dataSnapshot.toString());
Mesure mesure = dataSnapshot.getValue(Mesure.class);
Log.d(TAG, "Mesure : " + mesure.toString());
//temperature.setText(mesure.temperature.toString() + "°C");
humidite.setText(mesure.humidite.intValue() + " %");
horodatage.setText(getHorodatageFormate(mesure.timestamp));
Double temperature = mesure.temperature*10.;
//gaugeTemperature.setValue(temperature.intValue());
gauge.moveToValue(mesure.temperature.floatValue());
gauge.setLowerText(mesure.temperature.toString() + "°C");
gaugeHumidite.setValue(mesure.humidite.intValue());
}
}
@Override
public void onChildChanged(DataSnapshot dataSnapshot, String prevChildKey) {}
@Override
public void onChildRemoved(DataSnapshot dataSnapshot) {}
@Override
public void onChildMoved(DataSnapshot dataSnapshot, String prevChildKey) {}
@Override
public void onCancelled(DatabaseError databaseError) {}
});*/
}
private String getHorodatageFormate(String horodatage)
{
horodatage = horodatage.substring(0, 10) + " " + horodatage.substring(11, 19);
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.FRENCH);
df.setTimeZone(TimeZone.getTimeZone("UTC"));
Date date = null;
String horodatageFormate = horodatage;
try
{
date = df.parse(horodatage);
df.setTimeZone(TimeZone.getDefault());
horodatageFormate = df.format(date);
Log.d(TAG, "horodatageFormate : " + horodatageFormate);
}
catch (ParseException e)
{
e.printStackTrace();
}
return horodatageFormate;
}
}
Résultat :
Code source : MyApplicationFirebase.zip
Google Cloud fournit une API pour :
Exemple : Google Cloud IoT Core Python
On va tout d’abord tester à partir de Cloud Shell :
$ vim requirements.txt
google-cloud-iot==1.0.0
$ pip install -r requirements.txt
$ vim sendConfig.py
from google.cloud import iot_v1
project_id = 'test-esp32-iot'
cloud_region = 'europe-west1'
registry_id = 'mes-esp32-iot'
device_id = 'esp32-1'
version = 0
config= 'your-config-data'
print('Set device configuration')
client = iot_v1.DeviceManagerClient()
device_path = client.device_path(project_id, cloud_region, registry_id, device_id)
data = config.encode('utf-8')
client.modify_cloud_to_device_config(device_path, data, version)
Test :
$ python sendConfig.py
On obtient sur le moniteur série :
messageReceived <- /devices/esp32-1/config - your-config-data
$ vim sendCommand.py
from google.cloud import iot_v1
project_id = 'test-esp32-iot'
cloud_region = 'europe-west1'
registry_id = 'mes-esp32-iot'
device_id = 'esp32-1'
print('Sending command to device')
client = iot_v1.DeviceManagerClient()
device_path = client.device_path(project_id, cloud_region, registry_id, device_id)
command = 'Hello esp32-1'
data = command.encode('utf-8')
client.send_command_to_device(device_path, data)
Test :
$ python sendCommand.py
On obtient sur le moniteur série :
messageReceived <- /devices/esp32-1/commands - Hello esp32-1
On va maintenant créer une Cloud Function avec un déclenchement par une requête HTTP pour envoyer des commandes à l’ESP32 :
$ mkdir sendCommand
$ cd sendCommand
$ vim requirements.txt
google-cloud-iot==1.0.0
Flask==1.1.2
$ vim main.py
from google.cloud import iot_v1
import flask
def sendCommand(request):
request_args = request.args
if request_args and "deviceID" and "command" in request_args:
device_id = request_args["deviceID"]
command = request_args["command"]
print('Device : {}'.format(device_id))
print('Command : {}'.format(command))
data = command.encode('utf-8')
project_id = 'test-esp32-iot'
cloud_region = 'europe-west1'
registry_id = 'mes-esp32-iot'
client = iot_v1.DeviceManagerClient()
device_path = client.device_path(project_id, cloud_region, registry_id, device_id)
client.send_command_to_device(device_path, data)
return "Send command to {}".format(flask.escape(device_id))
else:
print('Args : {}'.format(request_args))
return "Error !"
On déploie :
$ gcloud functions deploy sendCommand --runtime python37 --trigger-http --allow-unauthenticated
Deploying function (may take a while - up to 2 minutes)...done.
availableMemoryMb: 256
entryPoint: sendCommand
httpsTrigger:
url: https://us-central1-test-esp32-iot.cloudfunctions.net/sendCommand
ingressSettings: ALLOW_ALL
labels:
deployment-tool: cli-gcloud
name: projects/test-esp32-iot/locations/us-central1/functions/sendCommand
runtime: python37
serviceAccountEmail: test-esp32-iot@appspot.gserviceaccount.com
sourceUploadUrl: https://storage.googleapis.com/gcf-upload-us-central1-5e43917f-c19b-40fd-b40c-5e03dd87a41d/4845af3d-848b-449f-86a8-649aea2e03a2.zip?GoogleAccessId=service-580197265786@gcf-admin-robot.iam.gserviceaccount.com&Expires=1592844829&Signature=...
status: ACTIVE
timeout: 60s
updateTime: '2020-06-22T16:25:17.370Z'
versionId: '1'
Remarque : pour les tests, on oublier la partie Authentification (--allow-unauthenticated
) !
On teste :
$ curl https://us-central1-test-esp32-iot.cloudfunctions.net/sendCommand
Error !
$ curl 'https://us-central1-test-esp32-iot.cloudfunctions.net/sendCommand?deviceID=esp32-1&command=on'
Send command to esp32-1
On obtient :
messageReceived <- /devices/esp32-1/commands - on
Le moyen le plus simple d’interagir avec l’ESP32 via Google Assistant est d’utiliser IFTTT.
IFTTT est un service web gratuit permettant de créer des chaînes d’instruction simples appelées applets.
On va par exemple créer un applet avec un déclencheur sur le service Google Assitant (This) avec une action Webhooks vers la Cloud Function sendCommand
(That) :
Il faut choisir le service Google Assistant :
On sélectionne “Say a simple phrase” :
On complète :
On va ajouter le That :
On sélectionne Webhooks :
On sélectionne “Make a web request” :
On entre l’URL de la Cloud Function :
On termine le processus de création :
On vérifie l’état connected
:
Test :
Il est possible de développer et de déployer des applications compatibles avec Google assistant grâce à la plateforme Action On Google. Cette dernière permet d’intégrer des applications grâce à Dialogflow de Google ou développées en Node.Js.
Dialogflow : Tarification et Quotas
Cette plateforme permettra de :
Un agent Dialogflow est un agent virtuel qui gère les conversations avec les utilisateurs. Il s’agit d’un module de compréhension du langage naturel qui saisit les nuances du langage humain. Dialogflow traduit les contenus texte ou audio produits par l’utilisateur au cours d’une conversation en données structurées assimilables par les applications et les services.
En savoir plus : Agents
Les agents servent également de conteneur pour :
On démarre en se connectant à la console Actions :
Il faut ensuite accepter les conditions de service :
Puis associer le projet :
On définit les premiers paramètres :
On crée une action :
Dans Dialogflow, on crée un agent :
Ensuite à partir du menu, on va pouvoir créer des intents (éventuellement des entities), définir le fulfillment et intégrer Google Assistant :
Exemple de création d’un intent :
Exemple de création d’une entity :
Il est possible de personnaliser l’accueil :
Et de faire l’intégration avec Google Assistant via Actions :
La partie fulfillment sera renseignée par la suite car il faut maintenant développer l’application via une Cloud Function Firebase. On possède déjà la fonction esp32_pubsub_to_firebase
qui enregistre la télémétrie dans Realtime Database :
On va réaliser les deux actions suivantes :
esp32_pubsub_to_firebase
pour enregistrer les valeures courantes de la température et d’humidité dans un chemin spécifique (ce qui permettra de les récupérer plus facilement) :const functions = require('firebase-functions');
// https://firebase.google.com/docs/functions/write-firebase-functions
// The Firebase Admin SDK to access the Firebase Realtime Database:
const admin = require('firebase-admin');
admin.initializeApp();
exports.esp32_pubsub_to_firebase = functions.pubsub.topic('esp32-iot-capteurs').onPublish(
(message, context) => {
const deviceId = message.attributes.deviceId;
const timestamp = context.timestamp
const temperature = message.json.temperature;
const humidite = message.json.humidite;
console.log(`Device=${deviceId}, Timestamp=${timestamp} - Temperature=${temperature}°C - Humidite=${humidite}%`);
// Début de l'ajout
admin.database().ref(`capteurs_esp32_iot/esp32_iot/temperature/value`).set(temperature);
admin.database().ref(`capteurs_esp32_iot/esp32_iot/humidite/value`).set(humidite);
// fin de l'ajout
return admin.database().ref(`capteurs_esp32_iot/${deviceId}`).push({
timestamp: timestamp,
temperature: temperature,
humidite: humidite
})
});
On obtiendra ceci :
$ cd test-esp32-iot/functions
$ ls
index.js node_modules package.json package-lock.json
$ npm install --save actions-on-google firebase-admin
$ vim index.js
const functions = require('firebase-functions');
// https://firebase.google.com/docs/functions/write-firebase-functions
// The Firebase Admin SDK to access the Firebase Realtime Database:
const admin = require('firebase-admin');
admin.initializeApp();
...
const { dialogflow } = require('actions-on-google')
//const app = dialogflow({debug: true})
const app = dialogflow()
function getTemperature() {
return admin.database().ref(`capteurs_esp32_iot/esp32_iot/temperature`).once("value").then(function(snapshot) {
var data = snapshot.val().value;
return data;
});
}
function getHumidite() {
return admin.database().ref(`capteurs_esp32_iot/esp32_iot/humidite`).once("value").then(function(snapshot) {
var data = snapshot.val().value;
return data;
});
}
app.intent('temperature', (conv) => {
return getTemperature().then(function(data) {
console.log("Temperature : " + data);
conv.ask(`La température est de ${data} degrés. Vous pouvez demander aussi quelle est l'humidité ?`);
});
});
app.intent('humidite', (conv) => {
return getHumidite().then(function(data) {
console.log("Humidite : " + data);
conv.ask(`L'humidité est de ${data} pourcent. Vous pouvez demander aussi quelle est la température ?`);
});
});
app.intent('aideTemperature', (conv) => {
conv.ask(`Voulez-vous connaître la température de Mon ESP ? Dites : quelle est la température ?`);
});
app.intent('aideHumidite', (conv) => {
conv.ask(`Voulez-vous connaître l'humidité de Mon ESP ? Dites : quelle est l'humidité ?`);
});
app.intent('aide', (conv) => {
conv.ask(`Vous pouvez demander à Mon ESP : quelle est la température ? ou quelle est l'humidité ?`);
});
exports.monesp = functions.https.onRequest(app)
On doit commencer par instancier un objet dialogflow
:
const { dialogflow } = require('actions-on-google')
//const app = dialogflow({debug: true})
const app = dialogflow()
Il faut pouvoir récupérer un enregistrement (contenant la température ou l’humidité) de Firebase en précisant le chemin dans l’appel ref()
puis en demandant sa valeur (value
).
On utilisera la méthode once()
pour obtenir la valeur une seule fois (la méthode on()
récupère aussi la valeur une fois mais elle définit également un rappel à appeler à chaque mise à jour).
Les fonction getTemperature()
et getHumidite()
doivent être des fonctions asynchrones. Pour cela, il faut renvoyer une promesse (cf. Promise en JavaScript). Cette promesse retournera la valeur récupérée lorsque la fonction asynchrone se terminera. La bibliothèque Firebase peut renvoyer une promesse dans le cadre de la clause .then()
de la réponse.
function getTemperature() {
return admin.database().ref(`capteurs_esp32_iot/esp32_iot/temperature`).once("value").then(function(snapshot) {
var data = snapshot.val().value;
return data;
});
}
Les gestionnaires d’intention app.intent()
doivent aussi utiliser une promesse si un appel asynchrone est réalisé (car le gestionnaire d’intention doit savoir attendre la fin d’un appel asynchrone). Il faut donc définir la réponse dans la partie .then()
puis appeler conv.ask()
pour énoncer la réponse :
app.intent('temperature', (conv) => {
return getTemperature().then(function(data) {
console.log("Temperature : " + data);
conv.ask(`La température est de ${data} degrés. Voulez-vous autre chose ?`);
});
});
On termine par définir la Cloud Function nommée ici monesp
:
exports.monesp = functions.https.onRequest(app)
On déploie :
$ firebase deploy --only functions
=== Deploying to 'test-esp32-iot'...
i deploying functions
i functions: ensuring required API cloudfunctions.googleapis.com is enabled...
i functions: ensuring required API cloudbuild.googleapis.com is enabled...
✔ functions: required API cloudbuild.googleapis.com is enabled
✔ functions: required API cloudfunctions.googleapis.com is enabled
i functions: preparing functions directory for uploading...
i functions: packaged functions (34.4 KB) for uploading
✔ functions: functions folder uploaded successfully
i functions: updating Node.js 10 function esp32_pubsub_to_firebase(us-central1)...
i functions: updating Node.js 10 function monesp(us-central1)...
✔ functions[monesp(us-central1)]: Successful update operation.
✔ functions[esp32_pubsub_to_firebase(us-central1)]: Successful update operation.
✔ Deploy complete!
Project Console: https://console.firebase.google.com/project/test-esp32-iot/overview
La nouvelle fonction monesp
est disponible :
On peut maintenant renseigner l’URL du fullfilment :
C’est parti pour les tests ! Il faut ouvrir le simulateur :
“Parler avec Mon ESP” :
Et on peut tester les différents intents :
Actuellement, c’est la version de test qui est déployée. On peut déployer une release en renseignant les différents paramètres suivants :
Liens : 10 Best IoT Platforms To Watch Out In 2020 et 12 IoT Platforms for Building IoT Projects