M5Paperでデジタルモノクロフォトフレームを作る(後編)

  • 2022-02-01
  • 2022-02-01
  • IoT
  • 1117回
  • 0件
IoT

こんにちは。CTO室のringoです。
前回の記事ではプロトタイプということで、画像の取得から表示までを行いました。
今回の記事ではGoogleDrive APIを使用して画像をダウンロードする部分を実装していきます。

実装の流れ

前回の記事で立てた目標は「実生活で使える」でした。実装するに当たって仕様を固めていきます。

画像をダウンロードして表示するところまでは確認ができています。
今回やらなければならないことは大きく分けてこの2つです。

  • Driveの画像をダウンロードするためにGoogleDriveAPIの設定をする
  • デバイスからGoogleDriveAPIを使用して画像をダウンロードする

GoogleDriveAPIの使い方について調べるとGoogleのドキュメントの他、ブログ記事なども出てきます。OAuth2.0の仕組みでアクセストークンを含むクレデンシャルがあればAPIを使うことができるようです。
アクセストークンの取得から画像の表示までの大まかな流れとしては以下になります。

  1. アクセストークンの取得
  2. 画像一覧の取得
  3. 画像一覧から画像を取得
  4. タイマーをセットしてスリープ

取得から表示までのフローは、画像の更新のたびにGoogleDriveにアクセスして画像の一覧を取得し、そこからランダムで表示する画像を選択するようにしました。
完全にランダムにすると同じ画像が表示されてしまう可能性があるため、前回の画像とは違う画像を表示するようにします。
後々DeepSleepモードを使用することを考えて前回表示した画像のIDはメモリ上ではなくESP32のnvsに保存するようにします。nvsとはESP32内のフラッシュメモリのことです。
画像の更新頻度は好みだと思うのですが今回は2時間ごとに設定しました。

ポイントとしては、実装を楽にするために、アップロードする画像をスマホアプリでM5Paperサイズにリサイズしています。こうすることでダウンロードした画像をそのまま表示できます。
また、フローをシンプルにすることでDeepSleepの復帰時(再起動時)も同じ処理で動作するようになります。

将来的にはM5Paper側面のスイッチで画像を選択するだとか、更新時間をデバイス上で変更できる機能を追加したいと思います。

Google Drive API の利用準備

Google Drive APIを使用するためにはGoogle Cloud PlatformのコンソールにアクセスしてAPIを使用するための設定をしなければなりません。

Googleのドキュメントを参考にしながら進めていきます。

Google Drive APIを使用できるようにするまでの準備は次のステップになります。

  1. GCP上でプロジェクトの作成
  2. DriveAPIの有効化
  3. OAuth同意画面の設定(スコープの設定)
  4. クレデンシャルの作成
  5. リフレッシュトークンの取得

1. GCP上でプロジェクトの作成

GCPにログインし、左側のナビゲーションメニューからIAMと管理 > プロジェクトの作成を選択します。
プロジェクト名は適当に入力してください。

2. DriveAPIの有効化

プロジェクトが作成され、コンソールの上部にプロジェクト名が表示されているのを確認すると、次は使用する機能を有効化します。
ナビゲーションメニューからAPIとサービス > ライブラリを選択します。

APIの一覧が表示されるのでその中からDriveAPIを選択し、有効にするを選択します。

3. OAuth同意画面の設定(スコープの設定)

OAuth同意画面の設定をします。
ナビゲーションメニューからAPIとサービス > OAuth同意画面を選択します。

OAuth同意画面ではテストユーザーの追加から、使用するGoogleアカウントを追加します。ここでアカウントを追加しないと認証時にエラーが出ます。

次の画面でスコープを設定します。今回はDrive内のファイル一覧と、ファイルのダウンロードができたらよいので、https://www.googleapis.com/auth/drive.readonlyだけにチェックを入れます。

4. クレデンシャルの作成

次にクレデンシャルを作成します。
ナビゲーションメニューからAPIとサービス > 認証情報を選択します。
認証情報の画面から認証情報を作成 > OAuthクライアントIDを選択します。

アプリケーションの種類をウェブアプリケーションにします。
承認済みのリクエストURIにhttp://localhost/を入力します。

作成されたクレデンシャルがJSON形式でダウンロードできるので保存しておきます。

一部伏せてますが、ファイルの中身はこのようになっています。

{
    "web": {
        "client_id": "00000000-xxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
        "project_id": "test-project-339216",
        "auth_uri": "https://accounts.google.com/o/oauth2/auth",
        "token_uri": "https://oauth2.googleapis.com/token",
        "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
        "client_secret": "GOCSPX-xxxxxx-xxxxxxxxxxxx",
        "redirect_uris": [
            "http://localhost/"
        ]
    }
}

5. リフレッシュトークンの取得

DriveAPIを使用するのにアクセストークンが必要ですが、このアクセストークンには有効期限が設定されています。アクセストークンの有効期限が切れた場合、リフレッシュトークンを発行しているなら新たにアクセストークンを取得することができます。
そこで、M5Paperにはリフレッシュトークンを書き込んでおき、画像の更新のたびにアクセストークンを再取得してAPIへアクセスします。

ここまででリフレッシュトークンを取得するのに必要な設定とクレデンシャルが揃いました。
リフレッシュトークンの発行にはサンプルのスクリプトを使います。どの言語でもいいのですが今回はPythonを使いました。

Python Quickstartに記載されているquickstart.pyを使用します。
ソースコードの中の変更点が一箇所あります。
SCOPES = [‘https://www.googleapis.com/auth/drive.metadata.readonly’]となっているのを
SCOPES = [‘https://www.googleapis.com/auth/drive.readonly’]と変更します。

このquickstart.pyは何してるかを簡単に説明すると、DriveAPIにリクエストを投げファイルの一覧を取得するプログラムです。OAuth2.0の認可コードフローをgoogle_auth_oauthlibライブラリが担当してくれています。
flow = InstalledAppFlow.from_client_secrets_file(‘credentials.json’, SCOPES)の部分)

実行方法としてはまず、quickstart.py実行するために必要なライブラリをインストールします。
pip install –upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib
そして、先程ダウンロードしたcredentials.jsonを実行フォルダに配置して実行します。

ブラウザにリダイレクトされるので、画像のように進みます。

公開ステータスがテストなため、以下の画像のような表示がされますが、そのままContinueを選択します。

認証に成功すると設定しておいたlocalhostにリダイレクトされ、quickstart.pyを実行したディレクトリにtoken.jsonが生成さます。
token.jsonの中身はこの様になります。

{
    "token": "ya29.xxxxxxxxxxxxxxxxxxxVG4zPks_k",
    "refresh_token": "1//0eLBrwxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxJZ6CMcq-xGY",
    "token_uri": "https://oauth2.googleapis.com/token",
    "client_id": "00000000-xxxxxxxxxxxxxxxxx.apps.googleusercontent.com.apps.googleusercontent.com",
    "client_secret": "GOCSPX-xxxxxx-xxxxxxxxxxxx",
    "scopes": [
        "https://www.googleapis.com/auth/drive.readonly"
    ],
    "expiry": "2022-01-25T05:31:25.195850Z"
}

“token”: “ya29.xxxxxxxxxxxxxxxxxxxVG4zPks_k”の部分がアクセストークンです。アクセストークンと共にリフレッシュトークンも発行されていることが確認できます。
こうして取得したリフレッシュトークンをデバイスに書き込むことでM5PaperからGoogleDrive APIの使用を可能にしています。

ただ、テスト用のアプリケーションだとリフレッシュトークンの有効期限は1週間なため注意が必要です。
参考:OAuth2.0を使用してGoogleAPIにアクセスする

M5Paperへの実装について

ここまででGoogle Drive APIを使用する準備が整いました。
実装していく内容は以下になります。

  1. アクセストークンの取得
  2. 画像一覧の取得
  3. 表示する画像を選択
  4. 画像のダウンロード
  5. 画像の描画
  6. DeepSleepのセット

1. アクセストークンの取得

リフレッシュトークンを使用したM5Paperでのアクセストークンの取得について説明します。
GoogleのチュートリアルではPythonやNode.jsのサンプルがありますが、今回はESP32で実装するためリクエストを作成しなければなりません。

ソースコードを見ながら説明します。

String get_access_token(void)
{
    WiFiClientSecure *client = new WiFiClientSecure;
    if (client)
    {
        client->setCACert(rootCA);
        {
            HTTPClient https;
            if (https.begin(*client, "https://" + host + "/oauth2/v4/token"))
            {
                String postData = "";
                postData += "refresh_token=" + refresh_token;
                postData += "&client_id=" + client_id;
                postData += "&client_secret=" + client_secret;
                postData += "&grant_type=refresh_token";
                https.addHeader("Content-Type", "application/x-www-form-urlencoded");
                int httpResponseCode = https.POST(postData);
                Serial.println(httpResponseCode);
                String body = https.getString();
                deserializeJson(doc, body);
                delay(10);
                https.end();
            }
        }
    }
    client->stop();
    String access_token = doc["access_token"];
    return access_token;
}

HTTPSでアクセスするためWiFiClientSecure.hとHTTPClient.hライブラリを使用しています。

処理の流れとしてはCA証明書をセットし、ホストに対して接続を開始します。接続に成功したら、POSTパラメータにリフレッシュトークンやクライアントIDを設定してPOSTします。

リクエストに成功すると以下のようなJSONが返ってきます。

{
  "access_token": "ya29.*****************************************Tm3hckN3AeMOT9FSGxLTPASwJeg",
  "expires_in": 3599,
  "scope": "https://www.googleapis.com/auth/drive.readonly",
  "token_type": "Bearer"
}

JSONのパースにはArduinoJsonを使用しています。
レスポンスはJSONの形で返却されるのでパースするために、一度Stringで受け取り、deserializeJsonでJSON型に格納します。最後にaccess_tokenだけを抽出して返します。

2. 画像一覧の取得

画像一覧の取得も流れとしてはアクセストークンの取得と同じでリクエストを組み立てて送信し、返ってきたJSONをパースします。ただ、リクエストをする際に先程取得したアクセストークンを付与します。

ファイル一覧が取得したいので使用するAPIはGET https://www.googleapis.com/drive/v3/filesです。
このAPIはqパラメーターを付け加えることで検索することができます。

参考:Search for files and folders

この資料によると特定のフォルダのファイル一覧を取得するには`’appDataFolder’ in parents`を付け加えます。
GoogleDrive側の用意として適当なディレクトリに表示したい画像を保存しておきます。

ディレクトリのIDが必要になるのですが、これはブラウザでGoogleDriveを開いたときのURLのhttps://drive.google.com/drive/u/0/folders/appDataFolderのappDataFolderの部分のことです。

void drive_files(void)
{

    WiFiClientSecure *client = new WiFiClientSecure;
    if (client)
    {
        client->setCACert(rootCA);
        {
            HTTPClient https;

            String postData = "?q=" + dirId + "+in+parents";

            if (https.begin(*client, "https://" + host + "/drive/v3/files" + postData))
            {
                https.addHeader("Content-Type", "application/json");
                https.addHeader("Authorization", "Bearer " + access_token);
                int httpResponseCode = https.GET();
                Serial.println(httpResponseCode);
                String body = https.getString();

                StaticJsonDocument<200> filter;
                filter["files"][0]["id"] = true;
                filter["files"][0]["name"] = true;
                deserializeJson(filter_body, body, DeserializationOption::Filter(filter));
                serializeJsonPretty(filter_body, Serial);

                int i = 0;
                while (1)
                {
                    String temp_id = filter_body["files"][i]["id"];
                    String temp_name = filter_body["files"][i]["name"];
                    if (temp_id.equals("null"))
                    {
                        break;
                    }
                    images[Str2str(temp_id)] = Str2str(temp_name);
                    i++;
                }

                https.end();
            }
            else
            {
                Serial.println("Connection failed");
            }
        }
    }
    client->stop();
}

アクセストークンの取得の時との違いは以下の通りです。

  • POSTからGETになったこと
  • クエリパラメータが追加されたこと
  • ヘッダーにAuthorization: Bearer access_tokenが追加されたこと

レスポンスは以下のようにJSON形式で返ってきます。

{
 "kind": "drive#fileList",
 "incompleteSearch": false,
 "files": [
  {
   "kind": "drive#file",
   "id": "15o9Xq3sTIp8HfcN7M3aLkubXAWxUXw0c",
   "name": "1636343198384.jpg",
   "mimeType": "image/jpeg"
  },
  {
   "kind": "drive#file",
   "id": "14kOpl1ci2FPfQrcEoLF6tRnOB-afAk1Z",
   "name": "IMG_19102021_192425.jpg",
   "mimeType": "image/jpeg"
  },
  {
    ......
  }
 ]
}

このままだと使いづらいのでdeserializeJsonでフィルターをかけIDとファイル名だけの形に変換しています。その後、C++のstd::mapに格納しています。
開発中にJSONを格納するメモリが足りずエラーになることがあったので多目にメモリを確保するか、動的にメモリを確保できるように工夫が必要です。

3. 表示する画像を選択

取得した画像一覧からランダムで次に表示する画像を選択します。

void setup()
{
    ......
    randomSeed(analogRead(33));
    ......
}

String selectImageID()
{
    String oldID = prefs.getString("imageid");
    auto it = images.begin();
    do
    {
        advance(it, random(images.size()));
    } while (!oldID.compareTo(it->first.c_str()));

    return it->first.c_str();
}

ランダムな選択にはArduinoに用意されているrandom()を使用します。シード値には未接続ピンのアナログ値を設定します。setup関数のrandomSeed(analogRead(33));でシード値を設定しています。
こうすることでシード値を毎回変更して乱数を得ています。これは未接続ピンのノイズを使用する方法です。
ただランダムに選択してしまうと前回と同じ画像が表示されてしまう可能性があるので、前回の画像と同じだったらもう一度選択し直すようにしています。

プログラム中のString oldID = prefs.getString(“imageid”);はnvsから前回表示した画像IDを取得しています。ESP32はリセットされるとメモリ上のデータは消えてしまうので、前回表示した画像IDはnvsに保存しています。

4. 画像のダウンロード

ファイルの取得に使用するAPIは
https://www.googleapis.com/drive/v3/files/fileIdです。画像のダウンロードのプログラムはM5Paperのサンプルプログラムhttps://github.com/m5stack/M5EPD/blob/main/src/M5EPD_Canvas.cpp#L1117を参考にしています。

int getPic_drive(String image_id, uint8_t *&pic)
{
    WiFiClientSecure *client = new WiFiClientSecure;
    if (client)
    {
        client->setCACert(rootCA);
        {
            HTTPClient https;
            Serial.println("HTTPS GET");

            if (https.begin(*client, "https://" + host + "/drive/v3/files/" + image_id + "?alt=media"))
            {
                https.addHeader("Authorization", "Bearer " + access_token);
                int httpResponseCode = https.GET();
                Serial.println(httpResponseCode);
                size_t size = https.getSize();
                Serial.print("Pyload Size ");
                Serial.println(size);
                if (httpResponseCode == HTTP_CODE_OK || httpResponseCode == HTTP_CODE_MOVED_PERMANENTLY)
                {
                    WiFiClient *stream = https.getStreamPtr();
                    pic = (uint8_t *)ps_malloc(size);
                    size_t offset = 0;
                    while (https.connected())
                    {
                        size_t len = stream->available();
                        if (!len)
                        {
                            delay(1);
                            continue;
                        }
                        stream->readBytes(pic + offset, len);
                        offset += len;
                        if (offset == size)
                        {
                            break;
                        }
                    }
                }
                https.end();
                return size;
            }
            else
            {
                Serial.println("Connection failed");
            }
        }
    }
    client->stop();
}

5. 画像の表示

GoogleDriveからダウンロードした画像をcanvas.drawJpg()に渡してM5Paperに表示しています。

void drawPic_drive(void)
{
    String imageid = selectImageID();
    uint8_t *pic = nullptr;

    int size = getPic_drive(imageid, pic);

    Serial.print("Size : ");
    Serial.println(size);

    canvas.createCanvas(540, 960);
    canvas.setTextSize(3);
    canvas.drawJpg(pic, size);

    canvas.pushCanvas(0, 0, UPDATE_MODE_GC16);

    free(pic);
    pic = NULL;

    prefs.putString("imageid", imageid);
    delay(500);
}

画像の表示後、画像のデータを開放しnvsに表示した画像のIDを保存します。
書き換え処理を抜けてすぐにDeepSleepに入ると画像が更新されなかったのでDelayを挿入しています。

6. DeepSleepのセット

ESP32にはDeepSleepと呼ばれる低消費電力でスリープするモードが搭載されています。
使用するにはesp_sleep_enable_timer_wakeupでタイマーをセットし、esp_deep_sleep_startでスリープに入ります。DeepSleepモードはメモリの状態が保存されないためnvsにデータを保存します。

uint64_t time = 7200000000;
esp_sleep_enable_timer_wakeup(time);
esp_deep_sleep_start();

タイマーはマイクロ秒単位で設定できます。スリープからの復帰は再起動と同じなので、void setup()から処理が始まります。記事の最後にソースコード全体を載せているので細かいところまで気になる方はご覧ください。

おわりに

エラー処理してなかったりと甘いところはあるのですが、ひとまず完成しました。
実際に部屋において2,3日使用してみたのですが、気づいたら表示されている画像が変わっているのは面白いですね。

開発する中で、HTTPSの暗号処理がブラウザだと意識しないのに対しマイコンだと重かったり、メモリサイズについて考えなければならないと、制限や不便なところもありますがそこが組み込みというかIoTの面白いところでもあると思います。

実装してみて、色々と機能を追加できそうなので今後もっと遊びたいなと思います。
お読みいただきありがとうございました。

最後になりますが、この記事で解説したソースコードの全体はこちらです。
M5PhotoPaper