時節柄、人が集まった場合に換気を促すようにしようと思った。
そこで、現在使っている Netatmo Weather Station のCO2濃度の情報を基に、合成音声を出力することを試すことにした。
最初は、Raspberry Pi などの Linux を使おうかと思ったが、ESP32 でも必要なライブラリがあり、比較的容易にプログラムが作成できそうなので、スピーカーのついている M5Atom Echo で動作させることにした。
まずは、Netatmo のAPIを利用してCO2濃度を取得する部分を作成した。
機能的には単純なプログラムだが、JSONの処理に使った ArduinoJson のバッファサイズが不足していて、正常に動作せず、原因がわかるまでに時間がかかった。後で示すプログラム例では、4096にしているが、Netatmoのステーションに接続されているデバイスが多い場合には不足する可能性があるので、調整が必要である。
また、Netatmo の API では、access token を取得し、それを利用して観測データを取得するようになっている。access token には有効期限があることになり、refresh token を利用して、access token をリフレッシュすることになっている。
しかし、access token、refresh token はいつも同じで、リフレッシュをしても access token が変わらない。それで良いものなのか?
また、予想よりも Netatmoへの HTTP request がタイムアウトを含めた失敗率が高く、エラー時のリトライをそれなりにしないと、処理が進まなくなったりした。
備忘録として残しておく。
目次
使用機材と環境
資料機材
開発環境
Visual Studio Code 1.51.1
PlatformIO IDE for VSCode Core 5.0.3 Home 3.3.1
利用したライブラリの依存グラフ
Dependency Graph |-- <M5Atom> 0.0.1 | |-- <FastLED> 3.3.3 | | |-- <SPI> 1.0 | |-- <Wire> 1.0.1 |-- <FastLED> 3.3.3 | |-- <SPI> 1.0 |-- <ArduinoJson> 6.17.2 |-- <ArduinoLog> 1.0.3 |-- <WiFi> 1.0 |-- <WiFiClientSecure> 1.0 | |-- <WiFi> 1.0 |-- <HTTPClient> 1.2 | |-- <WiFi> 1.0 | |-- <WiFiClientSecure> 1.0 | | |-- <WiFi> 1.0
プログラム
Netatmo Weather Station のCO2濃度の情報を取得する部分をまず実装した。
https://dev.netatmo.com/ に記載されている API を利用して、Netatmo から情報を取得する。https の post request によって、認証を行い、access token を取得する。
OAuth2を利用したインタフェースが用意されているが、M5Atomのようなブラウザを持たないデバイス向きのインタフェースがない。
しかたがないので、「This method should only be used for personal use and testing purpose. 」と書いてあるユーザ名とパスワードを指定する方法を使うことにした。
https でのアクセスのためには、WiFiClientSecureを利用した。 arduino-esp32 に含まれるサンプルプログラム BasicHttpsClient.ino を基にしてネットワーク部分を作成する。
request に対する response は JSON形式である。必要なデータを抽出するために ArduinoJson ライブラリを用いた。
netatmo-access.cpp
netatmo APIを利用して二酸化炭素濃度を取得するための関数を含んでいる。
/**
This file is based on BasicHTTPSClient.ino
https://github.com/espressif/arduino-esp32/tree/master/libraries/HTTPClient/examples/BasicHttpsClient
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <ArduinoLog.h>
#include "mysettings.h"
#define CO2_WARNING_LEVEL 800
//#define DISABLE_LOGGING
#define JSON_DOC_SIZE 4096
String serverURL = "https://api.netatmo.com/oauth2/token";
String apiURL = "https://api.netatmo.com/api";
String separator = "&";
String getTokenMsg = "grant_type=password" + separator
+ "client_id=" + MY_NETATMO_CLIENT_ID + separator
+ "client_secret=" + MY_NETATMO_CLIENT_SECRET + separator
+ "username=" + MY_NETATMO_USERNAME + separator
+ "password=" + MY_NETATMO_PASSWORD + separator
+ "scope=read_station";
String refreshTokenMsg = "grant_type=refresh_token" + separator
+ "client_id=" + MY_NETATMO_CLIENT_ID + separator
+ "client_secret=" + MY_NETATMO_CLIENT_SECRET + separator;
String getStationDataURL = apiURL + "/getstationsdata?"
+ "device_id=" + MY_NETATMO_DEVICE_ID + "&"
+ "get_favorites=false";
String
postRequest(WiFiClientSecure *client, String serverURL, String message, String tag) {
HTTPClient https;
String value;
Log.notice("postRequest(%X, %s, %s, %s)\n", client, serverURL.c_str(), message.c_str(), tag.c_str());
if (https.begin(*client, serverURL)) {
https.addHeader( "User-Agent", "ESP32/1.0");
https.addHeader( "Connection", "close");
https.addHeader( "Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
// start post request
int httpCode = https.POST(message);
// httpCode will be negative on error
if (httpCode > 0) {
// HTTP header has been send and Server response header has been handled
Log.notice("httpCode: %d\n", httpCode);
String response = https.getString();
Log.notice("response=%s\n", response.c_str());
// file found at server
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) {
DynamicJsonDocument doc(JSON_DOC_SIZE);
deserializeJson(doc, response);
JsonObject obj = doc.as<JsonObject>();
value = obj[tag].as<String>();
}
} else {
Log.warning("[HTTPS] POST... failed, error: %s\n", https.errorToString(httpCode).c_str());
}
https.end();
} else {
Log.warning("[HTTPS] https.begin failed...\n");
}
Log.notice("value=%s\n", value.c_str());
return value;
}
String
getAccessToken(WiFiClientSecure *client) {
String tag = "access_token";
String accessToken = postRequest(client, serverURL, getTokenMsg, tag);
return accessToken;
}
String
getRefreshToken(WiFiClientSecure *client) {
String tag = "refresh_token";
String refreshToken = postRequest(client, serverURL, getTokenMsg, tag);
return refreshToken;
}
String
refreshAccessToken(WiFiClientSecure *client, String refreshToken) {
String tag = "access_token";
String refreshAccessTokenBody = refreshTokenMsg + "refresh_token=" + refreshToken;
String accessToken = postRequest(client, serverURL, refreshAccessTokenBody, tag);
return accessToken;
}
String
getRequest(WiFiClientSecure *client, String requestURL, String accessToken) {
HTTPClient https;
String response;
Log.notice("getRequest(%X, %s, %s)\n", client, requestURL.c_str(), accessToken.c_str());
if (https.begin(*client, requestURL)) {
//https.addHeader( "Host", host);
https.addHeader( "Accept", "application/json");
https.addHeader( "Authorization", "Bearer " + accessToken);
Log.notice("[HTTPS] GET...\n");
// start connection and send HTTP header
int httpCode = https.GET();
// httpCode will be negative on error
if (httpCode > 0) {
// HTTP header has been send and Server response header has been handled
Log.notice("[HTTPS] GET... code: %d\n", httpCode);
response = https.getString();
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) {
Log.notice("httpCode=%d, response=%s\n", httpCode, response.c_str());
} else {
Log.warning("httpCode=%d, response=%s\n", httpCode, response.c_str());
}
} else {
Log.warning("[HTTPS] GET... failed, error: %s\n", https.errorToString(httpCode).c_str());
}
https.end();
} else {
Serial.print("[HTTPS] https.begin failed...\n");
}
return response;
}
int
getCO2value(WiFiClientSecure *client, String accessToken, String locationName) {
int co2value = -1;
String response = getRequest(client, getStationDataURL, accessToken);
if ( response != "" ) {
DynamicJsonDocument doc(JSON_DOC_SIZE);
deserializeJson(doc, response);
JsonObject obj = doc.as<JsonObject>();
JsonObject body = obj["body"];
JsonArray devices = body["devices"];
for ( auto device : devices ) {
String station_name = device["station_name"];
String module_name = device["module_name"];
Log.notice("station_name=%s, module_name=%s, locationName=%s ==> %d\n", station_name.c_str(), module_name.c_str(), locationName.c_str(), ( locationName == module_name ) );
if ( locationName == module_name ) {
co2value = device["dashboard_data"]["CO2"];
Log.notice("co2value=%d\n", co2value);
} else {
JsonArray modules = device["modules"];
for (auto module : modules ) {
module_name = module["module_name"].as<String>();
Log.notice("module_name=%s\n", module_name.c_str());
if ( locationName == module_name ) {
co2value = module["dashboard_data"]["CO2"];
Log.notice("co2value=%d\n", co2value);
}
}
}
}
}
return co2value;
}
void cleanupClient(WiFiClientSecure *client) {
if (client->connected()) client->stop();
delete client;
}
String composeMessage(String targetLocation, bool forced) {
Log.notice("composeMessage(%d)\n", forced);
WiFiClientSecure *client = new WiFiClientSecure;
if ( client == NULL ) {
Log.error("Unable to create client\n");
return "_fail";
}
// get access token
String accessToken = getAccessToken(client);
if ( accessToken == "" ) {
Log.error("getAccessToken failed.\n");
cleanupClient(client);
return "_fail";
}
// get CO2 value
int co2value = getCO2value(client, accessToken, targetLocation);
if ( co2value < 0 ) {
Log.error("getCO2value failed.\n");
cleanupClient(client);
return "_fail";
}
// clean up client
cleanupClient(client);
String message;
if ( co2value >= CO2_WARNING_LEVEL || forced ) {
message = targetLocation + "のCO2濃度は、" + co2value + " ppmです。";
}
if ( co2value >= CO2_WARNING_LEVEL ) {
message = message + "窓を開けるなどして、換気してください。";
}
Log.notice("message='%s'\n", message.c_str());
return message;
}
m5atom-netatmo.cpp
m5atom-netatmo.cpp は、netatmo-access.cppで定義されている関数のテストプログラムである。二酸化炭素濃度を取得して、テキストとして出力する。10分おきに取得とテキスト出力を繰り返すようにしている。
テストプログラムでは access token のリフレッシュの確認を行うために、まず最初に 関数getRefreshTokenを使ってrefresh token を取得し、それを使用した関数 refreshAccessTokenによって (リフレッシュした)access token を取得している。
このような使い方の場合には、refresh token やリフレッシュは不要であり、関数 getAccessTokenによって最初から access token を取得すれば良い。
access token を使い、関数getCO2value によって、Netatmo weather station のデータを取得する。
関数 decrementFuse はエラー時に呼び出し、エラーの回数が INITIAL_RESET_FUSE を超えた場合には、システムをリセットし、プログラムを最初から実行する。
関数 restoreFuse は、エラーの回数をリセットする。
m5stack atom echo/lite の 1つの(最初の)LED を初期状態でBlue、正常動作中はGreen、エラー発生時はRed、待ち時間中はYellowになるようにしている。
#include <Arduino.h>
#include <M5Atom.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WiFiClientSecure.h>
#include <ArduinoLog.h>
#include "mysettings.h"
#include "netatmo-access.h"
#define INTERVAL_IN_MINUTE 10
const char* ssid = MY_WIFI_SSID;
const char* password = MY_WIFI_PASSWORD;
WiFiMulti WiFiMulti;
String targetLocation = MY_TARGET_LOCATION;
String speaker = MY_GOOGLE_HOME;
#define GREEN_COLOR 0xff0000
#define RED_COLOR 0x00ff00
#define BLUE_COLOR 0x0000ff
#define YELLOW_COLOR 0xffff00
void setLEDRed() {
M5.dis.drawpix(0, RED_COLOR);
}
void setLEDGreen() {
M5.dis.drawpix(0, GREEN_COLOR);
}
void setLEDBlue() {
M5.dis.drawpix(0, BLUE_COLOR);
}
void setLEDYellow() {
M5.dis.drawpix(0, YELLOW_COLOR);
}
#define INITIAL_RESET_FUSE 5
#define RETRY_DELAY_IN_SEC 10
//declare reset function at address 0
void(* resetFunc) (void) = 0; // CPU reset
static int fuse = INITIAL_RESET_FUSE;
void restoreFuse() {
fuse = INITIAL_RESET_FUSE;
setLEDGreen();
}
void decrementFuse() {
setLEDRed();
if ( fuse-- <= 0 ) {
resetFunc(); // reset
} else {
delay(RETRY_DELAY_IN_SEC*1000);
}
}
// print current time
void printCurrentTime() {
time_t nowSecs = time(nullptr);
struct tm timeinfo;
gmtime_r(&nowSecs, &timeinfo);
Serial.print("Current time: ");
Serial.print(asctime(&timeinfo));
}
// Setting clock just to be sure...
void setClock() {
configTime(0, 0, "ntp.nict.jp", "pool.ntp.org", "time.nist.gov");
Serial.print("Waiting for NTP time sync: ");
time_t nowSecs = time(nullptr);
while (nowSecs < 8 * 3600 * 2) {
delay(500);
Serial.print(".");
yield();
nowSecs = time(nullptr);
}
Serial.println();
printCurrentTime();
}
void setup() {
Serial.begin(115200);
M5.begin(true, false, true);
Log.begin(LOG_LEVEL_VERBOSE, &Serial, true);
//Log.begin(LOG_LEVEL_WARNING, &Serial, true);
setLEDBlue();
Serial.println();
Serial.println();
Serial.println();
WiFi.mode(WIFI_STA);
WiFiMulti.addAP(ssid, password);
// wait for WiFi connection
Serial.print("Waiting for WiFi to connect");
while ((WiFiMulti.run() != WL_CONNECTED)) {
Serial.print(".");
decrementFuse();
}
restoreFuse();
Serial.println(" connected");
IPAddress ipAddress = WiFi.localIP();
Log.notice("IP address: %d.%d.%d.%d\n", ipAddress[0], ipAddress[1], ipAddress[2], ipAddress[3]); //Print the local IP
byte mac[6];
WiFi.macAddress(mac);
//Log.notice("MAC: %x:%x:%x:%x:%x:%x\n", mac[5], mac[4], mac[3], mac[2], mac[1], mac[0]);
// sync clock with NTP
setClock();
}
time_t waitingFor() {
int minute = INTERVAL_IN_MINUTE;
String waitingMessage = String("Waiting for ") + minute + " minute(s) before the next round...";
Log.notice("%s\n", waitingMessage.c_str());
return time(nullptr) + minute*60;
}
time_t nextTime = 0;
void loop() {
time_t currentTime = time(nullptr);
bool pressed = M5.Btn.isPressed();
if ( nextTime < currentTime || pressed ) {
setLEDGreen();
WiFiClientSecure *client = new WiFiClientSecure;
if ( client == NULL ) {
Log.error("Unable to create client\n");
decrementFuse();
return;
}
// get refresh token
String refreshToken = getRefreshToken(client);
if ( refreshToken == "" ) {
Log.error("getRefreshToken failed.\n");
cleanupClient(client);
decrementFuse();
return;
}
// refresh access token
String accessToken = refreshAccessToken(client, refreshToken);
if ( accessToken == "" ) {
Log.error("refreshAccessToken failed.\n");
cleanupClient(client);
decrementFuse();
return;
}
// get CO2 value
int co2value = getCO2value(client, accessToken, targetLocation);
if ( co2value < 0 ) {
Log.error("getCO2value failed.\n");
cleanupClient(client);
decrementFuse();
return;
}
// clean up client
cleanupClient(client);
String message = targetLocation + "のCO2濃度は、" + co2value + " ppmです。";
Serial.println(message);
printCurrentTime();
nextTime = waitingFor();
setLEDYellow();
}
M5.update();
}
netatmo-access.h
m5atom-netatmo.cppで定義している外部から利用する関数のプロトタイプ宣言を含んでいる。
#include <Arduino.h> #include <WiFiClientSecure.h> extern String getAccessToken(WiFiClientSecure *client); extern String getRefreshToken(WiFiClientSecure *client); extern String refreshAccessToken(WiFiClientSecure *client, String refreshToken); extern int getCO2value(WiFiClientSecure *client, String accessToken, String locationName); extern void cleanupClient(WiFiClientSecure *client); extern String composeMessage(String targetLocation, bool forced);
mysettings.h
環境に依存する値などを設定するヘッダファイルである。このファイルに含まれるマクロには適切な値を環境に合わせて適切に行う必要がある。
MY_WIFI_SSID、MY_WIFI_PASSWORD には WiFiのSSID 、パスワードを設定する。
MY_NETATMO_CLIENT_ID と MY_NETATMO_CLIENT_SECRET には、dev.netatmo.com にログインして作成したAppの情報を設定する。例をこの後に示す。
MY_NETATMO_USERNAME、MY_NETATMO_PASSWORD には、netatmo に登録してあるユーザ名(メールアドレス)とパスワードを設定する。
MY_NETATMO_DEVICE_ID には、NetatmoのステーションのMACアドレスを指定する。取得方法は後に示す。NetatmoのMACアドレスは、70:ee:50で始まるはず。「:」を「%3A」に変換して指定する。下の例では??の部分に対象のステーションに合わせた16進数を設定すれば良いはず。
MY_TARGET_LOCATION には、二酸化炭素濃度を取得したいデバイス名を指定する。次の例では、「ダイニング」になっているが、環境に合わせて設定する必要がある。
// WiFi #define MY_WIFI_SSID "MY_WIFI_SSID" #define MY_WIFI_PASSWORD "MY_WIFI_PASSWORD6" // Netatmo #define MY_NETATMO_CLIENT_ID "123456789abcdef012345678" #define MY_NETATMO_CLIENT_SECRET "123456789AbCdEfGhIjKlMnOp" #define MY_NETATMO_USERNAME "MY_NETATMO_USERNAME" #define MY_NETATMO_PASSWORD "MY_NETATMO_PASSWORD" #define MY_NETATMO_DEVICE_ID "70%3Aee%3A50%3A??%3A??%3A??" // 70:ee:50:??:??:?? #define MY_TARGET_LOCATION "ダイニング" // Google Home #define MY_GOOGLE_HOME "Google Home Mini"
dev.netatmo.com の My Apps に作成したAppの例
dev.netatmo.com のAPIを利用するためには、client id と client secret を取得する必要があり、そのためには、My Apps で App を作成する必要がある。
ここでは co2warning という App を作成した例を示す。MY_NETATMO_USERNAME、MY_NETATMO_CLIENT_ID、MY_NETATMO_CLIENT_SECRETに設定する値が示されているはずである。
Netatmo Weather Station の MACアドレスの取得
Netatmoのサイトにログインし、「設定」から「家を管理」を選択し、そこからステーションを選択する。
ステーションのモジュールを選択する。この例では「ダイニング」がステーションになっている。
最後の方にある MACアドレスの欄に記載の値がステーションのMACアドレスとなる。





