M5Stack Atom Echo にNetatmoの二酸化炭素濃度をしゃべらせる

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-ttssrc/google-tts.cppの中でtkk の取得の際にはタイムアウト処理がなされていない。tkkの取得の際にもタイムアウト処理が行われるように以下のコードの31行から38行の部分を54行から61行の部分にコピーした。

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);

開発環境

M5Stack Atom Echo

PlatformIO IDE for VSCode

ライブラリのバージョン

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のタイムアウト修正をしていないと、ずっと待ち続け、リセットするまで回復しないことがある。

#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 になる。

#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 に移動した。

/**
  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 を削除した。

#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);