M5StickCPlusからCloud Functions経由でツイートした日記

日記です。

見たところESP32からTwitterに低めのやる気で投稿する際に、AWS LambdaやCloudFunctionを噛ませている例がなかったので、書いておきます。

# はじめに

この度、初めてESP32関連のデバイスに触れました。やりたかったこととしては、物理ボタンを押すとツイートを投稿することです。

先例を探していくと以下の手法が見つかりました

- 外部の中継サービスにHTTPでツイート文を送信し、中継サービスがツイートする
- IFTTTにHTTPSで送信し、IFTTTがツイートする
- ESP32からツイートする。ライブラリあんまりないのでゴリゴリ実装する。

まず、HTTPは個人的には使いたくありません。IFTTTなどのサービスも費用がかかるので避けています。

　ESP32から直接ツイートを投稿するのは、これができるのがベストなのですが、既存のライブラリもろくになさそうです。[Twitter API から Arduino – ESP32 を使ってトレンドツイートを取得してみた](https://www.mgo-tec.com/blog-entry-twitter-api-arduino-esp32-trend-get.html)という先行事例はあるのですが、ここまでの気合は今日はありません。
　[Espruino on ESP32 で Twitter(その3) - いっぺーちゃんの いろいろやってみよ～](https://ippei8jp.hatenablog.jp/entry/2017/07/25/065328)はJavaScriptでツイート投稿をしています。終わってから見つけましたが、これでもよかったな:thinking:

というわけで、次のようなフローで組みました。M5StickからCloudFunctionにはお気楽HTTPSリクエストを投げ、CloudFunctionからTwitterに投稿してもらいます。先の例でいう中継サービスを自分で作る感じですね。
![やる気のない図.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45240/023fd54c-b71f-83fc-2c63-39a22fb06942.png)

なんとなく多数派っぽいArduinoので書いていましたが、JavaScriptやPythonでも書けるらしいですね。

# ボタンを押すとCloudFunctionを発火させる

HTTPSを叩く方法です。先例がいろいろあります。

- [Arduino – ESP32 WiFiClientSecure ライブラリで、安定して https ( SSL )記事をGETする方法 ](https://www.mgo-tec.com/blog-entry-arduino-esp32-ssl-stable-root-ca.html)
- [WiFiClientSecure - Arduinoで遊ぶページ](https://garretlab.web.fc2.com/arduino/esp32/examples/WiFiClientSecure/WiFiClientSecure.html)

画面の表示のリファレンスです。
- [M5Displayクラスの使い方 -  M5StickC非公式日本語リファレンス ](https://lang-ship.com/reference/unofficial/M5StickC/Tips/M5Display/)


ボタンの押し方です。
- [M5StickCのButtonクラスを調べてみた - Lang-ship](https://lang-ship.com/blog/work/m5stickc-button-class/)

いい感じに足し合わせるとこんな感じになります

<details><summary>スケッチ</summary>

```cpp
#include <M5StickCPlus.h>

#include <Arduino.h>

#include <WiFi.h>

#include <WiFiMulti.h>

#include <HTTPClient.h>

#include <WiFiClientSecure.h>


// Googleのルート証明書 Googleでない場合は異なる
const char * rootCACertificate = \
  "-----BEGIN CERTIFICATE-----\n"\
"MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw\n"\
"CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"\
"MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"\
"MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"\
"Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA\n"\
"A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo\n"\
"27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w\n"\
"Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw\n"\
"TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl\n"\
"qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH\n"\
"szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8\n"\
"Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk\n"\
"MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92\n"\
"wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p\n"\
"aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN\n"\
"VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID\n"\
"AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n"\
"FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb\n"\
"C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n"\
"QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy\n"\
"h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4\n"\
"7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J\n"\
"ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef\n"\
"MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/\n"\
"Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT\n"\
"6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ\n"\
"0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm\n"\
"2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb\n"\
"bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n"\
"-----END CERTIFICATE-----\n";

WiFiMulti WiFiMulti;

void setup() {

  Serial.begin(115200);

  Serial.println();
  // WiFiの初期設定・接続
  WiFi.mode(WIFI_STA);
  WiFiMulti.addAP("{WiFiのSSID}", "{WiFiのパスワード}");

  M5.begin();
  Serial.print("Waiting for WiFi to connect...");
  while ((WiFiMulti.run() != WL_CONNECTED)) {
    Serial.print(".");
    delay(250);
  }
  // 画面とシリアルにIPアドレスを出しておく
  Serial.println(WiFi.localIP());
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.println(WiFi.localIP());
}

void tweet(int button) {
  WiFiClientSecure client;
  client.setCACert(rootCACertificate);

  // 接続先
  const char * server = "asia-northeast1-your-project-name.cloudfunctions.net";

  if (!client.connect(server, 443))
    Serial.println("Connection failed");
  else {
    Serial.println("Connected to server");
    client.println(String("GET https://") + String(server) + String("/your-function-name?auth=12345&button=") + String(button == 0 ? "A" : "B") + String(" HTTP/1.0\r\n"));
    client.println(String("Host: ") + String(server));
    client.println("User-Agent: ESP32");
    client.println("Connection: close");
    client.println();
  }
  int status_code = 0;
  while (client.connected()) {
    String line = client.readStringUntil('\n');
    Serial.println(line);
    if (status_code == 0) {
      Serial.println(line.substring(9, 12));
      status_code = line.substring(9, 12).toInt();
    }
    if (line == "\r") {
      Serial.println("headers received");
      break;
    }
  }
  while (client.available()) {
    char c = client.read();
    Serial.write(c);
  }

  client.stop();
  M5.Lcd.setCursor(0, 0, 4);
  M5.Lcd.setTextSize(1);

  if (status_code == 200) {
    // ツイートをして色が変わると楽しい
    M5.Lcd.fillScreen(NAVY);
    M5.Lcd.setTextColor(WHITE, NAVY);
    M5.Lcd.println(String("Tweeted!\n") + String(button == 0 ? "A" : "B"));
  } else {
    M5.Lcd.fillScreen(PURPLE);
    M5.Lcd.setTextColor(WHITE, PURPLE);
    M5.Lcd.println("Tweet failed.");
  }
  delay(3000);

  M5.Lcd.fillScreen(BLACK);
}

void loop() {
  M5.update();

  M5.Lcd.setCursor(0, 12, 4);
  M5.Lcd.setTextColor(WHITE, BLACK);

  M5.Lcd.println("ready");

  if (M5.BtnA.wasPressed()) {
    Serial.println("BtnA.wasPressed() == TRUE");
    M5.Lcd.println("Sending...");
    tweet(0);
  } else if (M5.BtnB.wasPressed()) {
    Serial.println("BtnB.wasPressed() == TRUE");
    M5.Lcd.println("Sending...");
    tweet(1);
  }
  delay(200);

}
```

</details>


# CloudFunctionからツイートを送る

TwitterのAPIキーやアクセストークンなどを取得する必要があります。色々なサイトで紹介されています。Read/Write権限を貰うところまで忘れずにやりましょう。

- [Twitter APIを使うためのAPI KeyやAccess Tokenの取得方法｜アプリケーションの利用申請から紹介](https://wporz.com/twitterapi-apikey-accesstoken/)

ライブラリや実装はなんでもいいんですが[node-twitter-api-v2](https://github.com/PLhery/node-twitter-api-v2)を使いました。CloudFunctionは次の設定にしました。

|title|設定|
|---|---|
|環境|第１世代|
|トリガー|HTTP, 未認証の呼び出しを許可, HTTPSが必須|
|メモリ|128MB|
|タイムアウト|20秒|
|最小インスタンス数|0|
|最大インスタンス数|3|


<details><summary>CloudFunctionsのコード</summary>

```json:package.json
{
  "name": "sample-http",
  "version": "0.0.1",
  "dependencies": {
    "twitter-api-v2": "^1.12.0"
  }
}
```

```js:index.js
/**
 * Responds to any HTTP request.
 *
 * @param {!express:Request} req HTTP request context.
 * @param {!express:Response} res HTTP response context.
 */
const TwitterApiV2 = require('twitter-api-v2');

exports.helloWorld = async (req, res) => {
    if (req.query.auth === undefined || req.query.auth !== "12345qwerty") {
        res.status(403).send("")
        return
    }


    const appKey = "12345"
    const appSecret = "67890"
    const accessToken = "233456-abcde"
    const accessSecret = "abcdefg"

    const twitterClient = new TwitterApiV2.TwitterApi({
        appKey,
        appSecret,
        accessToken,
        accessSecret
    });

    const rwClient = twitterClient.readWrite;
    let tweetText = ""
    const jstNow = new Date((new Date()).getTime() + 9 * 3600 * 1000).toISOString().split('.')[0]

    const textList = [
        "にゃーん", "わんわん", "がおー"
    ]

    tweetText += textList[~~(Math.random() * textList.length)]
    tweetText += ` ${jstNow}`
    const {
        data: createdTweet
    } = await rwClient.v2.tweet(tweetText);
    console.log('Tweet', createdTweet.id, ':', createdTweet.text);
    let message = req.query.message || req.body.message || 'Hello World!';
    res.status(200).send(message);
};
```
</details>

# おわりに

お手軽に直接ツイートしたいのですが、M5Stick自体も初心者だったので、HTTPSを送信するところだけ頑張りました。面倒くさそうなところはCloudFunctionsに丸投げしています。AWS Lambdaとかでもできると思います。