「Netatmo APIを利用して二酸化炭素濃度を取得する」に続いて、取得した二酸化炭素濃度の値を基に M5Stack の ATOM Echo にしゃべらせることを試した。
目次
考え方
M5Stack ATOM Echo で URL を指定して再生することは ESP8266Audio を利用した次のサンプルプログラムが示されている。(ESP8266Audio という名前であるが、ESP32にも対応している)。
また、Google 翻訳を使ってText To Speech (TTS)するライブラリとして、次の esp8266-google-tts が利用できる (esp8266-google-ttsも、ESP32に対応している)。
上記を組み合わせる形で、以下の記事などで ATOM Echo にTTS で変換した音声を再生するプログラムが紹介されている。
作成したプログラムは、基本的に上記に基づいている。
修正点
基本的に上記で紹介されたものを使っているだけであるが、「How to play mp3 file from a website #113」にあるように、StreamHttpClient_ECHO よりも単純になるように、AudioFileSourceHTTPStream を使うようにしてみた。
また、手元のWiFiの環境が悪いせいかもしれないが、esp8266-google-tts で音声のURLを取得することに失敗し、タイムアウトもうまく働かない場合が結構あるので、タイムアウトを働かせるように少し修正してみた。
備忘録として記録を残しておく。
esp8266-google-tts のタイムアウト修正
esp8266-google-tts の src/google-tts.cppの中でtkk の取得の際にはタイムアウト処理がなされていない。tkkの取得の際にもタイムアウト処理が行われるように以下のコードの31行から38行の部分を54行から61行の部分にコピーした。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
String GoogleTTS::getTKK() { unsigned long current = millis(); bool bClientCreated = false; // WiFiClientSecure client; if (m_pClient == nullptr) { m_pClient = new WiFiClientSecure(); #if defined(ARDUINO_ARCH_ESP8266) && !defined(ARDUINO_ESP8266_RELEASE_BEFORE_THAN_2_5_0) m_pClient->setFingerprint(FINGERPRINT_GTRANS); #endif bClientCreated = true; } if (!m_pClient->connect(HOST_GTRANS, 443)) { Serial.println("connection failed"); if (bClientCreated == true) { delete m_pClient; m_pClient = nullptr; } return "_ERROR"; } m_pClient->print( String("GET / HTTP/1.0\r\n") + "Host: " + HOST_GTRANS + "\r\n" + "User-Agent: " + LIB_NAME + "/" + LIB_VERSION + "\r\n" + "Accept-Encoding: identity\r\n" + "Accept: text/html\r\n\r\n"); int timeout = millis() + 5000; m_pClient->flush(); while (m_pClient->available() == 0) { if (timeout < millis()) { m_pClient->stop(); if (bClientCreated == true) { delete m_pClient; m_pClient = nullptr; } return "_TIMEOUT"; } } String line = ""; boolean isHeader = true; do { line = m_pClient->readStringUntil('\r'); line.trim(); if (line.length() == 0) { isHeader = false; } if (isHeader) continue; String tkkFunc = ""; char ch; do { // add the following lines if (timeout < millis()) { m_pClient->stop(); if (bClientCreated == true) { delete m_pClient; m_pClient = nullptr; } return "_TIMEOUT"; } tkkFunc = ""; m_pClient->readBytes(&ch, 1); if (ch != 't') continue; tkkFunc += String(ch); m_pClient->readBytes(&ch, 1); if (ch != 'k') continue; tkkFunc += String(ch); m_pClient->readBytes(&ch, 1); if (ch != 'k') continue; tkkFunc += String(ch); } while(tkkFunc.length() < 3); |
開発環境
ライブラリのバージョン
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
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 |-- <ESP8266Audio> 1.7.0 | |-- <SPI> 1.0 | |-- <SPIFFS> 1.0 | | |-- <FS> 1.0 | |-- <HTTPClient> 1.2 | | |-- <WiFi> 1.0 | | |-- <WiFiClientSecure> 1.0 | | | |-- <WiFi> 1.0 | |-- <WiFiClientSecure> 1.0 | | |-- <WiFi> 1.0 | |-- <FS> 1.0 | |-- <SD(esp32)> 1.0.5 | | |-- <FS> 1.0 | | |-- <SPI> 1.0 |-- <esp8266-google-tts> 1.0.8 | |-- <WiFiClientSecure> 1.0 | | |-- <WiFi> 1.0 |-- <WiFi> 1.0 |-- <WiFiClientSecure> 1.0 | |-- <WiFi> 1.0 |-- <HTTPClient> 1.2 | |-- <WiFi> 1.0 | |-- <WiFiClientSecure> 1.0 | | |-- <WiFi> 1.0 |-- <SD(esp32)> 1.0.5 | |-- <FS> 1.0 | |-- <SPI> 1.0 |
プログラム
m5atom-netatmo-echo.cpp
メインプログラムである。
「Netatmo APIを利用して二酸化炭素濃度を取得する」と基本的な部分は同じである。二酸化炭素濃度を取得して、一定の基準を超えていたら、それを知らせる文字列を生成し、音声合成したファイルへのURLに変換し、M5Stack Atom Echo のスピーカーから再生する。一定時間毎に二酸化炭素濃度の取得から繰り返す。
Atom Echo のボタンを押せば基準を超えていない場合も二酸化炭素濃度を音声で知らせる。
String getGoogleSpeechUrl(String text) は、esp8266-google-tts を利用して、引数のtext を音声合成したファイルへの URL を戻す。変換に失敗する場合があるので、失敗した場合には繰り返すようにしている。上記の esp8266-google-ttsのタイムアウト修正をしていないと、ずっと待ち続け、リセットするまで回復しないことがある。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
#include <Arduino.h> #include <M5Atom.h> #include <WiFi.h> #include <WiFiMulti.h> #include <WiFiClientSecure.h> #include <ArduinoLog.h> #include <google-tts.h> #include "mysettings.h" #include "netatmo-access.h" extern int outputUrlToSpeaker(String url); #define CO2_WARNING_LEVEL 800 #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 cleanupClient(WiFiClientSecure *client) { if (client->connected()) client->stop(); delete client; } String composeMessage(String targetLocation, bool forced) { // targetLocation の二酸化炭素濃度が基準値 O2_WARNING_LEVEL を超えていたら // それを知らせる文字列を返す。 // forced が真の場合には、基準値に達していない場合も文字列を返す。 // そうでない場合には空文字列を返す。 Log.notice("composeMessage(%s, %d)\n", targetLocation.c_str(),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; } TTS googleTTS; String getGoogleSpeechUrl(String text) { // 引数の text を音声合成したファイルへのURLを返す。 Log.notice("getGoogleTTSUrl(%s)\n", text.c_str()); String url = googleTTS.getSpeechUrl(text, "ja"); while ( url.startsWith("_") ) { Log.notice("url=%s\n", url.c_str()); decrementFuse(); delay(1000); url = googleTTS.getSpeechUrl(text, "ja"); } restoreFuse(); String http = "http" + url.substring(5); Log.notice("http=%s\n", http.c_str()); return http; } 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(); String message = composeMessage(targetLocation, pressed); while ( message.startsWith("_") ) { decrementFuse(); delay(7*1000); message = composeMessage(targetLocation,pressed); } restoreFuse(); if ( message != "" ) { String url; url = getGoogleSpeechUrl(message); outputUrlToSpeaker(url); } printCurrentTime(); nextTime = waitingFor(); setLEDYellow(); } M5.update(); } |
output-url-to-speaker.cpp
int outputUrlToSpeaker(String url) で引数に与えられたURLの音を M5Stack Atmo Echo のスピーカーから出力する。
ソースコードの2行目の #include <SD.h> がないと、fatal error: vfs_api.h: No such file or directory になる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
#include <M5Atom.h> #include <SD.h> // これがないと vfs_api.h が見つからず fatal error: vfs_api.h: No such file or directory #include <ArduinoLog.h> #include "AudioFileSourceHTTPStream.h" #include "AudioFileSourceBuffer.h" #include "AudioGeneratorMP3.h" #include "AudioOutputI2S.h" #include "mysettings.h" AudioFileSourceHTTPStream *file; AudioFileSourceBuffer *buff; AudioGeneratorMP3 *mp3; AudioOutputI2S *out; #define CONFIG_I2S_BCK_PIN 19 #define CONFIG_I2S_LRCK_PIN 33 #define CONFIG_I2S_DATA_PIN 22 int outputUrlToSpeaker(String url) { Log.notice("outputUrlToSpeaker(%s)\n", url.c_str()); // audioLogger = &Serial; file = new AudioFileSourceHTTPStream( (const char *)url.c_str() ); // buff = new AudioFileSourceBuffer(file, 10240); out = new AudioOutputI2S(); out->SetPinout(CONFIG_I2S_BCK_PIN, CONFIG_I2S_LRCK_PIN, CONFIG_I2S_DATA_PIN); out->SetChannels(1); out->SetGain(0.6); mp3 = new AudioGeneratorMP3(); // mp3->begin(buff, out); mp3->begin(file, out); // while (mp3->isRunning()) { if (!mp3->loop()) { mp3->stop(); return -1; } M5.update(); } Log.notice("outputUrlToSpeaker done.\n"); return 0; } |
netatmo-access.cpp
「Netatmo APIを利用して二酸化炭素濃度を取得する」と同じであるが、composeMessage と cleanupClient を m5atom-netatmo-echo.cpp に移動した。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
/** 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 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; } |
netatmo-access.h
「Netatmo APIを利用して二酸化炭素濃度を取得する」と同じであるが、composeMessage と cleanupClient を削除した。
1 2 3 4 5 6 7 |
#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); |