ESP32のログはクラウドにおまかせ by Google Cloud IoT

# はじめに

ESP32のログはSerial.printlnで出力が基本ですが
これだとPC接続などが必要になるのと保存には工夫が必要になります。
独立して本番稼働時にもログが見れるようにクラウド環境に出力してサーバーいらずで長期間保存でどこでも見れるようにしたいと思います。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">念願のESP32とFirestoreの連携できた！<br>Firestore書き換えをトリガーにESP32に値を出せる<br>ESP32からStackdriver Loggingにログも書けたしクラウド保存できてこれも使えそう<br>少し遅延はあり。Firestoreは1秒、Stackdriver Loggingは約12秒(データ上は1秒）<br>Cloud IoTのコマンド送信のほうが瞬間で早い <a href="https://t.co/LeES0Q33pH">pic.twitter.com/LeES0Q33pH</a></p>&mdash; ode (@odetarou) <a href="https://twitter.com/odetarou/status/1214956934421303297?ref_src=twsrc%5Etfw">January 8, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 使用するもの

- マイコン
  - ESP32やESP8266(この記事はESP32にて説明します)
- クラウド環境(GoogleのGCP, Firebaseを利用)
  - Cloud IoT Core
  - Stackdriver Logging
  - Cloud Pub/Sub
  - Cloud Functions for Firebase

# 流れ

GCPのStackdriver LoggingのAPIを直接利用できればいいのですがESP32などのマイコン向けの実装が見つかりませんでした。
そこでGoogle側で非公式にサンプルを提供しているCloud Iot Core用のコードを利用します。

全体の流れ
ESP32 →[MQTT]→ Cloud Iot Core → Cloud Pub/Sub → Cloud Function → Stackdriver Logging
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/902a377c-9fba-e38c-b5a7-b27bead0bc7e.png)

Cloud Iot Coreは簡単にいうとMQTTプロトコルを経由してCloud Pub/Subにつなぐ仕組みになります（プロトコロルブリッジ）。
ESP32ではMQTTプロトコルの使用例は多いのでここらへんは安心して利用できそうです。
Cloud Functionを利用するとCloud Pub/Subにパブリッシュ(データ登録みたいなもの)されたイベントをトリガーに処理を実行できるため
その処理としてStackdriver Loggingにログを出力できます。

やってみるとわかるのですがCloud Functionが実行できるのでGCPの各種サービス(Big Query, Data Studio等)にデータをつないで様々なことができそうです。

# クラウド側設定(Cloud IoT Coreなど)

## 用語

- Cloud IoT Core
    - レジストリ
        - 複数デバイスをまとめる役割と、PubSubのTopicをひもづける概念。とりあえず１つ作る。
    - デバイス
        - ESP32などの接続するデバイスを表す。複数登録可能
- Cloud Pub Sub
    - トピック
        - パブリッシャーがメッセージ(データ)を送信する名前付きのリソース。とりあえず１つ作る。トピックにパブリッシュ(データ送信)するとサブスクライバーにデータが送信される。
    - パブリッシャー
        - データ送信側なので今回の場合はESP32などのデバイスがパブリッシャーとなる。
    - サブスクライバー
        - データ受信側なので今回の場合はCloud Functionがサブスクライバーとなる。


## 設定

下記の英語記事URLを参考にしました。記事自体のクライアントはNode.jsですがクラウド側設定はESP32でも同様になります。
https://cloud.google.com/community/tutorials/cloud-iot-logging?hl=ja

gcloudコマンドをインストール
下記URLを参考にインストールしてください。GCPのプロジェクトを作成し、その後`gcloud init`を実行し設定して下さい。
https://cloud.google.com/sdk/install?hl=ja
https://cloud.google.com/sdk/docs/initializing

firebaseコマンドをインストール
下記URLを参考にインストールしてください。またGCPのプロジェクトをもとにFirebaseプロジェクトを作成して下さい。
https://firebase.google.com/docs/cli/?hl=ja
`firebase init`を実行して`Cloud Functions`, `TypeScript`を選択。各種ファイルが生成されます。

- functions/src/index.ts
- functions/package.json

を後述のソースにて書き換えます。

Cloud Iot Coreなどの設定は画面でも設定できますが、簡単なので今回はgcloudコマンドにて作成していきます。こちらで生成後にGCPの管理画面にて何が作られたかを確認していくと理解が進むと思います。

Cloud Iot Core管理画面URL
https://console.cloud.google.com/iot/
事前にアクセスしてAPIを有効にして下さい。（課金設定が必要になりますが、無料の範囲内で利用できます）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/3a14a619-bbb1-3ee6-73a4-b60da6575a6c.png)

各種生成コマンド

```bash
# 設定値をあからじめ環境変数にて設定する
export REGISTRY_ID=test_registry # レジストリIDを入力（名前みたいなものでプロジェクトに適した任意の名前を設定して下さい）
export CLOUD_REGION=asia-east1
export GCLOUD_PROJECT=$(gcloud config list project --format "value(core.project)")

# pubsubのtopicを作成
gcloud pubsub topics create device-logs
# iot coreのレジストリを作成
gcloud iot registries create $REGISTRY_ID --region=$CLOUD_REGION --event-notification-config=subfolder="",topic=device-logs

# cloud functionsのデプロイ
cd functions
npm install
firebase use $GCLOUD_PROJECT
firebase deploy --only functions

# device用のフォルダ作成（鍵を作成して置くだけなのでどこでもよいです）
cd ../
mkdir sample-device
cd sample-device
# デバイスの認証に使用する公開鍵、秘密鍵を生成
openssl ecparam -genkey -name prime256v1 -noout -out ec_private.pem
openssl ec -in ec_private.pem -pubout -out ec_public.pem
# iot coreのデバイスを作成(公開鍵を登録している） sample-deviceの箇所は任意のデバイス名に書き換え可能です
gcloud iot devices create sample-device --region $CLOUD_REGION --registry $REGISTRY_ID --public-key path=./ec_public.pem,type=ES256
# 下記コマンドで秘密キーを抽出してpriv箇所をスケッチに記載する。
openssl ec -in ec_private.pem -noout -text
```

作成されたレジストリ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/55d3dc25-c3f9-8518-41b7-011ba8526f85.png)

作成されたデバイス。実際にデバイスを接続するとここで最終接続日時などが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/fa89d8b8-5b5b-d32c-4edf-e83b533b51a2.png)



**index.ts**
元記事のはデバイスから送られるログデータのやりとりにjsonを使っていますが今回は簡単にするためにテキストのやりとりに変更しています。
pubsubの"device-logs"という名前のtopicにパブリッシュされたのをトリガーにStackdriver Loggingにログを出力しています。
Cloud Iot Coreで送られるmessage.dataはBase64でエンコードされているのでデコードする必要があります。
regionはデフォルトだとus-central1になるためasia-northeast1に変更します。

```index.ts
'use strict';

require('source-map-support').install()

import * as functions from 'firebase-functions'
const loggingClient = require('@google-cloud/logging');

// create the Stackdriver Logging client
const logging = new loggingClient({
  projectId: process.env.GCLOUD_PROJECT,
});

exports.deviceLog =
  functions.region('asia-northeast1').pubsub.topic('device-logs').onPublish((message) => {
    const log = logging.log('device-logs');
    const metadata = {
      // Set the Cloud IoT Device you are writing a log for
      // you extract the required device info from the PubSub attributes
      resource: {
        type: 'cloudiot_device',
        labels: {
          project_id: message.attributes.projectId,
          device_num_id: message.attributes.deviceNumId,
          device_registry_id: message.attributes.deviceRegistryId,
          location: message.attributes.location,
        }
      },
      labels: {
        // note device_id is not part of the monitored resource, but you can
        // include it as another log label
        device_id: message.attributes.deviceId,
      }
    };
    const decodedData = new Buffer(message.data, 'base64').toString('ascii');

    const entry = log.entry(metadata, decodedData);
    return log.write(entry);

  });
```

**package.json**
元記事のだと古くてコンパイルが通らなかったためversionなどあげて各種追加しました。多分これも後ほど動かなくなりそうですが。。

```package.json
{
  "name": "functions",
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc",
    "serve": "npm run build && firebase serve --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log",
    "check": "gts check",
    "clean": "gts clean",
    "compile": "tsc -p .",
    "fix": "gts fix",
    "prepare": "npm run compile",
    "pretest": "npm run compile",
    "posttest": "npm run check"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^8.9.0",
    "firebase-functions": "^3.3.0",
    "@google-cloud/logging": "^1.2.0",
    "@google-cloud/iot": "^1.5.0",
    "googleapis": "^46.0.0",
    "source-map-support": "^0.5.16"
  },
  "devDependencies": {
    "@types/express": "^4.17.2",
    "@types/express-serve-static-core": "^4.17.1",
    "@types/node": "^12.12.21",
    "gts": "^1.1.2",
    "tslint": "^5.20.1",
    "typescript": "^3.7.3"
  },
  "private": true,
  "engines": {
    "node": "10"
  }
}
```



# ESP32ソース実装

下記がGoogle側でCloud Iot CoreにArduino系で接続する非公式のサンプルです。
こちらのソースをコピーして接続します。
https://github.com/GoogleCloudPlatform/google-cloud-iot-arduino

ESP32の場合は下記を利用します。
https://github.com/GoogleCloudPlatform/google-cloud-iot-arduino/tree/master/examples/Esp32-lwmqtt
ESP8266の場合は下記を。
https://github.com/GoogleCloudPlatform/google-cloud-iot-arduino/tree/master/examples/Esp8266-lwmqtt

３ファイルにて構成されています。

- Esp32-lwmqtt.ino
    - mainファイル。loop処理をこちらに実装します
- ciotc_config.h
    - configファイル。Wifi設定、秘密キーなどを設定
- esp32-mqtt.h
    - google-cloud-iot-arduinoのライブラリを少しラップしたメソッド群です。
    - demo用のコードやwifi接続なども記述されているため必要に応じて書き換えます。



## ciotc_config.h設定
configファイルです。下記設定をしてください。

```cpp
// パスワードを設定して下さい
// WIFI
const char *ssid = "xxx";
const char *password = "xxx";

...

// Cloud IoT Coreの各種設定をします
// Cloud iot details.
const char *project_id = "GCPのプロジェクトIDを記述"; // echo $GCLOUD_PROJECTで確認できます
const char *location = "asia-east1";
const char *registry_id = "test_registry"; // IoT CoreのレジストリーIDを記述
const char *device_id = "sample-device"; // IoT CoreのデバイスIDを記述

// Cloud IoT Coreのデバイスごとに設定する秘密キーを設定します。先頭が"00:"ではじまる始まる場合はその"00:"を消して合計32個のHEX値ペアになるようにして下さい(32byte)。
const char *private_key_str =
    "xx:xx:xx..."
    "xx:xx:xx..."
    "xx:xx:xx";

```

private_key_strに設定する各種生成コマンド時に生成した下記秘密キー部分を利用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/6da84347-72a5-af80-2910-71c824f72a02.png)

## Esp32-lwmqtt.ino

Arduinoのメインファイルでsetup, loopを実装します。
ログ出力テスト用に下記に書き換えます。
Serialモニターにて入力した文字列をログ出力するサンプルになります。
実行後に文字を入力して後述するログ確認方法で出力されるかを確認してみてください。

```cpp
#include "esp32-mqtt.h"

void setup() {
  Serial.begin(115200);
  // 下記にWifi接続が含まれます。
  setupCloudIoT();

  delay(10);  // <- fixes some issues with WiFi stability
  if (!mqttClient->connected()) {
    connect();
  }
  // 起動時のログ出力。再起動したこともこれが出力されていれば判別できるかと思います。
  log("app setup start");
}

void loop() {
  // 下記のloopメソッド内でSubscribeした処理(受信処理)が実行されます。ログ出力だけなら使用しません。
  mqtt->loop();
  delay(10);  // <- fixes some issues with WiFi stability

  if (!mqttClient->connected()) {
    connect();
  }

  String str = Serial.readStringUntil('\n');
  str.trim();
  if (str != "") {
    log(str);
  }
}

void log(String msg) {
  Serial.println(msg);
  // publish処理(送信処理)
  publishTelemetry(msg);
}

```

## esp32-mqtt.h
google-cloud-iot-arduinoのライブラリを少しラップしたメソッド群です。
基本的に書き換えは不要ですが
MQTTにてSubscribeした処理(受信処理)時の処理を定義する場合はmessageReceivedを書き換えて下さい。mainファイルのほうへ移動したほうがいいかもしれません。
wifi接続もこのファイルにて処理してるので別途記述する場合にも書き換えてもいいかもしれません。

```cpp
// Subscribeした処理(受信処理)は下記を書き換えてください。
void messageReceived(String &topic, String &payload) {
  Serial.println("incoming: " + topic + " - " + payload);
}
```

# 注意点
エラー処理は実装していません。
publishTelemetryでエラー時はfalseが返るため必要に応じて対応したほうがよいかもしれません。
loop内で切断時の接続処理をしておりあまりエラーが起きないので今のところはこのまま利用しています。（MQTT再接続時の数秒は欠損しそう。。）
ログを１つ足りとも逃したくないんだという要件の場合はメモリ,SPIFFS,SDカード等にバッファリングする実装をすると良さそうです。

# 料金
(2020/01/15 時点)

- Cloud IoT Core
  - データ通信量に応じて課金。
  - 月250MBまでは無料。
マイコンのデータやりとりには十分な量かと。
  - https://cloud.google.com/iot-core/?hl=ja
- Stackdriver Logging
  - 読み込まれて処理されたログデータの量に応じて課金
  - 月50GBまでは無料。十分すぎる！
  - https://cloud.google.com/stackdriver/pricing?hl=ja
- Firebase Cloud Function
  - 呼び出し回数, GB秒, CPU秒
  - 無料枠 呼び出し回数12.5万/月、 GB秒 CPU秒どちらも4万/月。これも十分すぎますね。
  - https://firebase.google.com/pricing?hl=ja

# Stackdriver Loggingについて

管理コンソールからログが確認できます。
http://console.cloud.google.com/logs/viewer
リソースはCloud IoT Deviceを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/cc09c27a-6e51-ea96-f4b3-f2f76bd6f7ca.png)
（我が家の給湯器に取り付けたESP32のログ。電源on, offの履歴がわかったり、Firebase Realtime DatabaseへのSteram再接続が高頻度で起きているのがわかります）

書き込み時から表示されるようになるまでは時間がかかります。大体12秒〜18秒かかるイメージです。本番稼働時には気にしないんですがデバッグ時にも利用できるように速度改善されるといいですね。
直近のをリアルタイムに見たい場合は下記も検討してみるとよいかもしれません。
参考URL:ESP-LINKでESP32のログをリモートで確認する (Arduinoも可)
https://qiita.com/odetarou/items/4de89877b12cb29d5e92

データの保持期限は30日間になります。
それ以上保存が必要な場合はシンクという設定にてCloud StrageやBig Tableに自動エクスポートできます。
参考URL:Stackdriver Loggingのログを失う前にエクスポートしておく方法
https://apps-gcp.com/stackdriver-logging-export-gcs/

参考URL：使用上限について
https://cloud.google.com/logging/quotas?hl=ja

gcloudコマンド実行にても確認可能です。デフォルトはjson出力されるのでjqコマンドで加工するやり方もあります。今回はformat指定で見やすくします。
https://cloud.google.com/logging/docs/reference/tools/gcloud-logging

```bash
gcloud logging read "logName: projects/プロジェクト名/logs/device-logs" --limit 10 --format="table(receiveTimestamp.date(tz=Asia/Tokyo), textPayload)"
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/c8460e90-35cd-7945-4da6-fd1177e40c58.png)


テスト書き込みもgcloudコマンドにて

```bash
gcloud logging write device-logs "テストログです"
```

pubsub経由での書き込みなら過去コマンドにて
```bash
gcloud pubsub topics publish device-logs --message "testです"
```
gcloudの各種コマンドは下記URLをみていくといろんなサービスの処理が全てコマンドでできて便利です。
https://cloud.google.com/sdk/gcloud/reference/pubsub/topics/publish?authuser=0&hl=ja


# MQTTについて
基本はSocketで常時接続しているので
都度HTTP APIコールよりはパフォーマンスはよいかと思います。（今回の実装は1時間おきにJWT(Json Web Token)を再発行して再接続しているようです）
またサーバーへの送信だけでなくサーバー側からの受信のイベントも対応しています。

# Cloud IoT Coreのその他いろいろ
## 特徴
- MQTTのプロトコルブリッジをして各種Googleのクラウドサービスとの連携が可能
- 公開鍵による認証でのセキュリティー確保。デバイスごとの認証の管理が可能

## データの送受信の方法。
下記４パターンあるようです。

- Configurations(設定)
- Commands（コマンド）
- Telemetry(テレメントリー)
- State(状態)

Configurations(設定)、Commands（コマンド）はCloud IoT Core → deviceへのデータ送信
 （Configurationsは1〜10秒間に1回だけだがCommandsのほうなら細かく実行できる。ただし現在ONLINEのデバイスにのみ送信になる。ConfigurationsはOFFLINEデバイスへの再送がある）

TelemetryとState(状態)はDevice → Cloud IoT Coreへのデータ送信
標準はTelemetryで送信となる。

## コマンド実行時の例

deviceへデータ送信する場合のgcloudコマンド
実行するとESP32上のmessageReceivedメソッドが呼ばれます。

```bash
gcloud iot devices commands send \
        --command-data="test message" \
        --region=asia-east1  \
        --registry=レジストリ名 \
        --device=デバイス名
```

Webコンソール上だと下記にて
<img width="1216" alt="デバイスの詳細 - googlehome-aog - Google Cloud Platform 2020-01-14 01-24-33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132679/4076664d-174b-276f-be43-4d995973a2b9.png">

ConfigurationsとCommandsの違いは下記URL(英語)が詳しいです
https://cloud.google.com/iot/docs/how-tos/commands


### QOS
QOSという概念も抑えておくとよさそうです。
0:メッセージが最高 1 回は配信されます。メッセージが送信先に確実に届くかの保証はされません。(Commandsがこれ)
1:メッセージが最低 1 回は配信されます。メッセージが送信先に確実に届く保証はされますが、重複してメッセージが届く可能性があります。(Configurationsがこれ)
2:メッセージが正確に 1 回配信されます。メッセージが送信先に確実に届く保証がされます。重複したメッセージが届くことはありません。(IoT Coreでは実装なし)

2がない理由は下記が参考になります(AWSの例ですが同様と思われます)
https://dev.classmethod.jp/cloud/aws/why-is-there-different-between-aws-iot-message-broker-and-general-one/

## Firestoreのデータ変更をトリガーにESP32にイベントを送信する
Cloud FunctionからもsendCommandToDeviceメソッドにて送信できます。
これを利用することでFirestoreのデータ変更をトリガーにESP32にイベントを送信することも可能です。
※Firestoreのデータ更新からデバイスでの受信まで1秒ほど遅延しました。早くなるといいのですがCloud Functionのトリガー自体が遅いみたいですね。

```ts
const iot = require('@google-cloud/iot');
const iotClient = new iot.v1.DeviceManagerClient();

// start cloud function
exports.firestoreUpdate = functions.region('asia-northeast1').firestore
// assumes a document whose ID is the same as the deviceid
.document('device-commands/{deviceId}')
.onWrite(async (change: functions.Change<firebase.firestore.DocumentSnapshot>, context?: functions.EventContext) => {
  if (context) {
    console.log(context.params.deviceId);
    const request = generateRequest(context.params.deviceId, change.after.data(), false);
    //return iotClient.modifyCloudToDeviceConfig(request);
    return iotClient.sendCommandToDevice(request);
  } else {
    throw(Error("no context from trigger"));
  }
});

function generateRequest(deviceId:string, configData:any, isBinary:Boolean) {
  const formattedName = iotClient.devicePath(process.env.GCLOUD_PROJECT, functions.config().iot.core.region, functions.config().iot.core.registry, deviceId);
  let dataValue;
  dataValue = Buffer.from(JSON.stringify(configData)).toString("base64");
  return {
    name: formattedName,
    binaryData: dataValue
  };
}
```

# 終わりに

Googleさんいろいろ無料でありがとう。
Cloud IoT Coreの基本サポートはラズパイとかでのNode.jsがターゲットに見えますけどNode.jsなら直接GCPのライブラリでつなげるのでCloud IoT Coreのメリット薄そうですよね。
どうせならESP32とかのIoTマイコン系のサポートを公式に充実してくると嬉しいですね（arduino-esp32自体が不安定なので公式にはやりづらいんでしょうが、、）