はじめに
本シリーズでは,Google Apps Script (GAS) と ESP32 を使って,外出先から自宅のPCをリモートで起動する方法を紹介しています。あくまで個人的な実践経験を共有することが目的のため正確性に欠ける部分があるかもしれませんが,参考にしていただければ幸いです。
前回の振り返り
前回は,Apps Script API を外部から呼び出すためにOAuth認証 と実際にAPI をリクエス トしてレスポンスを確認するテストを行いました。今回は,いよいよESP32にHTTPリクエス トを送信し,Apps Script API を呼び出して最終的にPCの電源をオンにするという一連の流れを実現します。
早速プログラムの作成に映りますが,まずは今までの内容を振り返りながら必要なライブラリ,環境設定,プログラムの全体像(UML )を把握しプログラムの作成へと移ります。
最後にはプログラムの解説と作成に至った経緯を書いているので興味があれば読んでください。
環境構築と必要条件
PCが遠隔で起動できるようにPCの設定を変更します。
▼ 以下のサイトを参考にして設定を変更してください。
eizone.info
ESP32の必要条件
ESP32でWake On LAN (WOL )機能を実現するためには,以下の設定を行いWOL が正しく機能することを確認する必要があります。
▼ 以下の手順を参考にWOL を完了させてください。
yhotta240.hatenablog.com
▼ 要約版
qiita.com
Apps Script API を利用するための環境構築
Google Sheetsの作成とApps Scriptプロジェクトの作成 : Google Sheetsを作成し,そこに関連付けるGoogle Apps Scriptプロジェクトを作成します。プロジェクトの設定で実行可能なAPI としてデプロイします。
yhotta240.hatenablog.com
クライアントアプリの作成 : Google Sheetsの拡張機能 からクライアントアプリとしてAppSheetを作成します。
Google Cloudプロジェクトの作成とOAuth認証 の設定 : Google Cloud Consoleでプロジェクトを作成し,Google Apps Scriptとの連携設定を行います。OAuth 2.0の認証設定を完了しAPI の利用を許可します。その際はAppSheetをクライアントアプリ(承認済みのリダイレクト URI )として設定しておきましょう。
API テスト : 上記の設定が完了したらAPI のテストを行い,正常に動作することを確認します。
yhotta240.hatenablog.com
プログラムの流れ
構成図
構成図
基本的にGASとESP32のコードを書き,他は環境構築です。
GASプログラム
getCellValue関数:シートのB2セルの値を取得しその値を返す
setCellValue関数:シートのB2セルに新しい値を更新
Code.gs
function getCellValue () {
const sheet = SpreadsheetApp. getActiveSpreadsheet () . getSheetByName ( 'シート1' ) ;
const value= sheet. getRange ( 'B2' ) . getValue () ;
return value;
}
function setCellValue ( value) {
const sheet = SpreadsheetApp. getActiveSpreadsheet () . getSheetByName ( 'シート1' ) ;
const cell = sheet. getRange ( 'B2' ) ;
cell. setValue ( value) ;
return { message : "セルB2の値を '" + value + "' に更新しました。" } ;
}
ESP32プログラムの全体の流れ
Wi-Fi に接続
PCのオンライン状態チェック
PCがオフかつGoogle Sheetsの値がFALSEからTRUEに代わっていた場合のみWOL する
Google Sheetsの値の値を更新
私はエンジニアではないのでUML の作成については詳しくないですが,以下のようなUML を作成しました。
クラス図
クラス図
アクティビティ図
アクティビティ図は,プログラムの処理の流れや条件分岐を示す図です。以下に示すアクティビティ図では,ESP32とGASを使ったWake On LAN のプロセスを可視化しています。
アクティビティ図
www.plantuml.com
これをもとにどの条件下でWOL が実行されるか見るために真理値表を書いてみると以下の通りになります。
真理値表
PCの状態
セルの値 getCellValue()
前回の値
セルの値更新 setCellValue(value )
オン
TRUE
TRUE
TRUE
オン
TRUE
FALSE
TRUE
オン
FALSE
TRUE
TRUE
オン
FALSE
FALSE
TRUE
オフ
TRUE
TRUE
FALSE
オフ
TRUE
FALSE
TRUE (WOL 実行)
オフ
FALSE
TRUE
FALSE
オフ
FALSE
FALSE
FALSE
不要な処理を取り除くと以下のように最大4つの分岐でよいことになります。
PCの状態
セルの値 getCellValue()
前回の値
セルの値更新 setCellValue(value )
操作
オン
取得しない
TRUE
何もしない
オフ
TRUE
TRUE
FALSE
何もしない
オフ
TRUE
FALSE
TRUE
WOL 実行
オフ
FALSE
FALSE
何もしない
このようにすることでユーザが値をFALSE(前回の値)からTRUE(セルの値)に変更したときのみWake On LAN を実行できるようにしてます。
使用するライブラリ
ESP32がApps Script API と通信するための必要なライブラリを準備します。Wake On Lan 機能を実現するために以下のライブラリを使用します。
ESP32の組み込みライブラリ
WiFi .h : ESP32をWi-Fi ネットワークに接続する
HTTPClient.h : HTTPリクエス トを送信し,Apps Script API と通信するために使用する
外部から追加するライブラリ
ArduinoJson.h : Apps Script API から返されるJSON 形式のデータを解析し,ESP32で利用可能なデータにする
WakeOnLan .h : 特定のMACアドレス を持つPCをリモートで起動する
ESPping.h : ネットワーク上のデバイス に対してPing を送信し,そのデバイス がオンラインかどうかを確認する
外部から追加するライブラリは,Arduino IDE のライブラリマネージャから検索してインストールします。
プログラムの作成
スケッチ
Arduino IDE でプログラム(スケッチ)の作成します。以下のプログラムを新規スケッチに貼り付けてください。
gas-eps32-wol .ino
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WakeOnLan.h>
#include <ESPping.h>
WiFiUDP UDP;
WakeOnLan WOL (UDP);
const char * ssid = "SSID" ;
const char * password = "PASSWORD" ;
String refresh_token = "YOUR_REFRESH_TOKEN" ;
String client_id = "YOUR_CLIENT_ID" ;
String client_secret = "YOUR_CLIENT_SECRET" ;
String api_url = "https://script.googleapis.com/v1/scripts/SCRIPT_ID:run" ;
const char * pcMacAddress = "AB:CD:EF:12:34:56" ;
const IPAddress pcIP (192 , 168 , 0 , 120 );
RTC_DATA_ATTR char access_token[256 ] = "" ;
RTC_DATA_ATTR bool previousValue = true ;
const uint64_t sleepIntervalOn = 15 * 60 * 1000000 ;
const uint64_t sleepIntervalOff = 5 * 60 * 1000000 ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (500 );
Serial.print ("." );
}
Serial.println (" \n WiFiに接続しました:" );
Serial.print ("IPアドレス: " );
Serial.println (WiFi.localIP ());
Serial.print ("前回のValue:" );
Serial.println (previousValue ? "true" : "false" );
Serial.print ("現在のアクセストークン:" );
Serial.println (String (access_token));
if (strlen (access_token) == 0 ) {
Serial.println ("アクセストークンが空のため再度リフレッシュします..." );
refreshAccessToken ();
}
bool isPcOn = checkPcStatus ();
if (isPcOn) {
if (!updateGASAPI ("TRUE" )) {
Serial.println ("アクセストークンが無効のため再度リフレッシュします..." );
refreshAccessToken ();
updateGASAPI ("TRUE" );
}
previousValue = true ;
}else {
Serial.println ("APIリクエストを実行します..." );
if (!getGASAPI ()) {
Serial.println ("アクセストークンが無効のため再度リフレッシュします..." );
refreshAccessToken ();
getGASAPI ();
}
}
WiFi.disconnect ();
Serial.println ("Wi-Fi接続を解除しました" );
if (isPcOn) {
Serial.println ("PCはオンラインです。15分間スリープします..." );
esp_sleep_enable_timer_wakeup (sleepIntervalOn);
} else {
Serial.println ("PCはオフラインです。5分間スリープします..." );
esp_sleep_enable_timer_wakeup (sleepIntervalOff);
}
esp_deep_sleep_start ();
}
void loop () {
}
bool checkPcStatus () {
Serial.print ("Pingを送信中..." );
bool isOnline = Ping.ping (pcIP);
if (isOnline) {
Serial.println ("PCはオンラインです。" );
return true ;
} else {
Serial.println ("PCはオフラインまたは到達不能です。" );
return false ;
}
}
bool getGASAPI () {
HTTPClient http;
http.begin (api_url);
http.addHeader ("Authorization" , "Bearer " + String (access_token));
http.addHeader ("Content-Type" , "application/json" );
String jsonPayload = "{ \" function \" : \" getCellValue \" }" ;
int httpResponseCode = http.POST (jsonPayload);
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
Serial.print ("HTTP/1.1 " );
Serial.println (httpResponseCode);
DynamicJsonDocument doc (1024 );
deserializeJson (doc, response);
bool value = doc["response" ]["result" ].as<bool >();
if (value && !previousValue) {
Serial.println ("PCにマジックパケットを送信します..." );
WOL.sendMagicPacket (pcMacAddress);
delay (1000 );
updateGASAPI ("TRUE" );
previousValue = value;
} else if (value && previousValue) {
Serial.println ("前回と値が変わらないため,Google SheetsをFALSEに更新します..." );
delay (1000 );
updateGASAPI ("FALSE" );
previousValue = !value;
} else if (!value) {
Serial.println ("PCはシャットダウン状態です。" );
delay (1000 );
updateGASAPI ("FALSE" );
previousValue = value;
}
return true ;
} else {
handleHttpError (httpResponseCode, http);
return false ;
}
http.end ();
}
bool updateGASAPI (String status) {
HTTPClient http;
http.begin (api_url);
http.addHeader ("Authorization" , "Bearer " + String (access_token));
http.addHeader ("Content-Type" , "application/json" );
String jsonPayload = "{ \" function \" : \" setCellValue \" , \" parameters \" : [ \" " + status + " \" ]}" ;
int httpResponseCode = http.POST (jsonPayload);
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
Serial.print ("HTTP/1.1 " );
Serial.println (httpResponseCode);
StaticJsonDocument<256 > doc;
deserializeJson (doc, response);
const char * message = doc["response" ]["result" ]["message" ];
Serial.print ("Google Sheetsの更新結果: " );
Serial.println (message);
return true ;
} else {
handleHttpError (httpResponseCode, http);
return false ;
}
http.end ();
}
void refreshAccessToken () {
HTTPClient http;
String refresh_token_endpoint = "https://oauth2.googleapis.com/token" ;
http.begin (refresh_token_endpoint);
String postData = "client_id=" + client_id + "&client_secret=" + client_secret + "&refresh_token=" + refresh_token + "&grant_type=refresh_token" ;
http.addHeader ("Content-Type" , "application/x-www-form-urlencoded" );
int httpResponseCode = http.POST (postData);
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
Serial.print ("HTTP/1.1 " );
Serial.println (httpResponseCode);
DynamicJsonDocument doc (1024 );
deserializeJson (doc, response);
String new_access_token = doc["access_token" ].as<String>();
new_access_token.toCharArray (access_token, sizeof (access_token));
if (strlen (access_token) > 0 ) {
Serial.println ("新しいアクセストークン: " + String (access_token));
} else {
Serial.println ("アクセストークンの取得に失敗しました" );
}
} else {
handleHttpError (httpResponseCode, http);
}
http.end ();
}
void handleHttpError (int httpResponseCode, HTTPClient& http) {
Serial.print ("HTTPリクエストでエラーが発生しました: HTTP/1.1 " );
Serial.println (httpResponseCode);
String errorResponse = http.getString ();
if (errorResponse.length () > 0 ) {
Serial.println ("エラーレスポンス: " );
Serial.println (errorResponse);
}
}
およそ200行のスケッチが完成しました。
検証
左上の検証ボタンをクリックし,検証を開始します。
検証
出力は以下のようになります。
最大1310720バイトのフラッシュメモリのうち、スケッチが1065001バイト(81%)を使っています。
最大327680バイトのRAMのうち、グローバル変数が47688バイト(14%)を使っていて、ローカル変数で279992バイト使うことができます。
書き込み
ESP32をPCとUSB接続して書き込みボタンをクリックして書き込みを開始します。
書き込みが完了するとシリアルモニタに結果が表示されるので確認します。エラーコードが表示されず,ESP32が正常にディープスリープモードに入ったことを確認できたら実行完了です。
実行イメージ図
実行テスト
書き込みが完了したら実行テストを行います。まずPCをシャットダウンします。次にESP32につないだUSBケーブルをUSB-ACアダプタにつなげてコンセントに差し込みます。
自分のスマホ からAppSheetを開き,自分のアカウントでログインします。最後にAppSheetで作成したアプリにアクセスしステータスをTRUEにしてSaveしましょう。数分後,自動でPCの電源が付いたら成功です。
実行テスト
実行テストに関しては自宅PC以外のPCを使ってシリアルモニタを監視しながら起動させたり,自分が操作していないのに勝手に電源が付いてしまうことがないようにいろんなパタンで試したりしてみてください。
中間まとめ
お疲れさまでした。ここまでくれば,Google Apps Script (GAS) と ESP32 を使って外出先からPC起動の実装は完了していると思います。これまでの内容を要点を絞って紹介してきましたが,あくまでこれは技術ブログであり目的は実践的な経験を共有することです。
ここからは,この実装に至るまでの経緯やプログラムの詳しい解説も紹介していますので,興味のある方はぜひ読んでみてください。実際にコードを書いて動作確認を行った部分なども含まれていますので,参考にしていただければと思います。
実装に至るまでの経緯
まずは,ESP32のWake On LAN プログラムを確認します。
▼詳しくはこちら
yhotta240.hatenablog.com
以下は,同じLAN環境内でESP32からPCを起動するWake On LAN プログラムになります。
esp32-wol-test.ino
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WakeOnLan.h>
WiFiUDP UDP;
WakeOnLan WOL (UDP);
const char * ssid = "SSID" ;
const char * password = "PASSWORD" ;
const char * pcMacAddress = "AA:BB:CC:DD:EE:FF" ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (1000 );
Serial.println ("WiFiに接続中..." );
}
Serial.println ("WiFiに接続完了" );
Serial.println (WiFi.localIP ());
WOL.sendMagicPacket (pcMacAddress);
Serial.println ("Wake on LANパケット送信" );
}
void loop () {
}
簡単にプログラムを説明すると
最初に必要なライブラリを読み込む
ssid
とpassword
にWi-Fi のSSID とPASSWORDを保存
pcMacAddressにPCのMACアドレス を保存
setup関数: プログラムの開始時に一度だけ実行され,Wi-Fi に接続が完了したらPCを起動するためのマジックパケット を送信
loop関数: テストのためここでは何も実行しない
このプログラムでは,ESP32が電源に接続されると一度だけPCを起動する動作を行います。
しかし,外部からいつでもPCを起動できるようにするには,ESP32を「常に稼働させておくこと」と「API で値を受け取る処理」を組み込む必要があります。そのため,まずは定期的な処理を行う方法を理解し,API との通信を定期的に行ってAPI の返り値がTRUEのときにESP32がPCの電源を入れるようにプログラムを変更します。
定期的に処理を行うには?
では,どのようにしてESP32で定期的な処理を行うのでしょうか?
ここでArduino IDE のプログラム仕様について見ていきます。
Arduino IDE はC/C++ をベースとしたArduino 言語でプログラミングします。
Arduino プログラムは,主に以下の2つの関数で構成されます。
1. setup() 関数
setup() 関数は,プログラムが始まった際に一度だけ実行されます。ここでは,初期設定やセンサー・ネットワークの接続などプログラム開始時に必要な設定を行います。例えば,ESP32でWi-Fi に接続する処理や初期化が必要なデバイス の設定がここに含まれます。
2. loop() 関数
loop() 関数は,setup() が完了した後に繰り返し実行される部分です。この関数内に書いた処理は,ESP32が動作している限りループし続けます。例えば,センサーの値を定期的に読み取ったり,サーバーに定期的にリクエス トを送ったりする処理をloop()関数内に書きます。
void setup () {
}
void loop () {
}
このようにArduino IDE では,プログラムを簡潔に2つの関数で構成できます。電源が入るとまずsetup()が実行され,続けてloop()で繰り返し処理が行われてデバイス を制御します。
定期的な処理
つまり,ESP32の電源が入っている状態ではloop()関数で定期的な処理を行うことができます。このloop()関数内にAPI で値を受け取る処理を記述すれば,定期的にAPI から値を取得し必要に応じてPC起動の制御ができます。
これらの基本を押さえた上で,次にESP32からAPI で値を受け取る処理を実装するにはどうしたらよいのでしょうか?
前回行った「API を叩いてみる」を見てみると良さそうですね
API で値を受け取る処理
API テストの振り返り
前回,Bash でcURL を使ってHTTPリクエス トを送信しAPI を呼び出しました。大まかな流れとしては次のようになっていました。
1. 新たな認証情報を作成する
Google Cloud コンソール にアクセスして認証情報のOAuth 2.0 クライアント IDを作成する
2. 認可コードを取得
以下のURLにアクセスするとGoogle アカウントの認証ページが表示され,承認すると認可コードを取得できます。
https://accounts.google.com/o/oauth2/v2/auth?
scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fspreadsheets&
access_type=offline&
include_granted_scopes=true&
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
prompt=consent
3. アクセストークンの取得
ターミナルを開き以下のコマンドでリクエス ト
curl -X POST https://oauth2.googleapis.com/token \
-d code=YOUR_AUTHORIZATION_CODE \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d redirect_uri=YOUR_REDIRECT_URI \
-d grant_type=authorization_code
するとこのようなレスポンスがJSON 形式で返されます。
{
"access_token": "ya29.***",
"expires_in": 3599,
"refresh_token": "***",
"scope": "https://www.googleapis.com/auth/spreadsheets",
"token_type": "Bearer"
}
4. Apps Script API の呼び出し
以下のコマンドでリクエス ト
getCellValue関数を実行したい場合
curl -X POST \
-H "Authorization: Bearer access_token" \
-H "Content-Type: application/json" \
-d '{"function": "getCellValue"}' \
https://script.googleapis.com/v1/scripts/***/run
するとこのようなレスポンスがJSON 形式で返されます。
{
"done": true,
"response": {
"@type": "type.googleapis.com/google.apps.script.v1.ExecutionResponse",
"result": true
}
}
5. アクセストークンの更新
アクセストーク ンの更新についてのリクエス トは次のようにします。
curl -X POST https://oauth2.googleapis.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "grant_type=refresh_token"
OAuth 2.0認証によりアクセストーク ンを受け取り,そのアクセストーク ンを使ってApps Script API にアクセスすることができました。
ESP32によるAPI 処理の実装
考え方としては,どの部分をESP32に任せるかを逆算して整理します。API を呼び出すためにはアクセストーク ンが必要なので,これをESP32に保存しておく必要がありますが,アクセストーク ンは一時間で無効になります。そのため認可コードまたはリフレッシュトーク ンを使って新しいアクセストーク ンを取得する必要があります。
まず認可コードでのアクセストーク ンの取得についてですが,認可コードは一度しか使用できず2回目以降ではアクセストーク ンを取得することができませんでした。
リフレッシュトーク ンを使うと何度でも新しいアクセストーク ンを発行できるため,アクセストーク ンが無効になっても問題なく処理を継続できます。このためリフレッシュトーク ンを使う方法が有効だと考えました。
次のステップとしては,ESP32がAPI を呼び出して正しく値を受け取れるか単体テスト を行います。
API呼び出しテスト
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
const char * ssid = "SSID" ;
const char * password = "PASSWORD" ;
String access_token = "YOUR_ACCESS_TOKEN" ;
String api_url = "https://script.googleapis.com/v1/scripts/SCRIPT_ID:run" ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (1000 );
Serial.println ("WiFiに接続中..." );
}
Serial.println ("WiFiに接続完了" );
Serial.println ("IPアドレス: " );
Serial.println (WiFi.localIP ());
getGASAPI ();
}
void loop () {
}
void getGASAPI () {
HTTPClient http;
http.begin (api_url);
http.addHeader ("Authorization" , "Bearer " + String (access_token));
http.addHeader ("Content-Type" , "application/json" );
String jsonPayload = "{ \" function \" : \" getCellValue \" }" ;
int httpResponseCode = http.POST (jsonPayload);
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
Serial.print ("HTTP/1.1 " );
Serial.println (httpResponseCode);
Serial.println ("APIのレスポンス: " );
Serial.println (response);
DynamicJsonDocument doc (1024 );
DeserializationError error = deserializeJson (doc, response);
if (!error) {
String value = doc["response" ]["result" ].as<String>();
Serial.print ("セルの値: " );
Serial.println (value);
} else {
Serial.println ("JSONの解析に失敗しました" );
}
} else {
Serial.print ("HTTPリクエストでエラーが発生しました: HTTP/1.1 " );
Serial.println (httpResponseCode);
String errorResponse = http.getString ();
Serial.println ("エラーレスポンス: " );
Serial.println (errorResponse);
}
http.end ();
}
テストの成功例
WiFiに接続中...
WiFiに接続完了
IPアドレス:
192.168.1.100
HTTP/1.1 200
APIのレスポンス:
{
"response": {
"result": "TRUE"
}
}
さらに次のステップとしてESP32で,リフレッシュトーク ンから正しく新しいアクセストーク ンを取得できるかのテストを行います。
リフレッシュトークンテスト
* Wi-Fi 接続の確認:SSID とパスワードを使ってWi-Fi に接続
* 新たなアクセストーク ンの取得:refreshAccessToken()関数を呼び出し,リフレッシュトーク ンを使って新たなアクセストーク ンを取得
#include <WiFi.h>
#include <WiFiUdp.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
const char * ssid = "SSID" ;
const char * password = "PASSWORD" ;
char access_token[256 ] = "YOUR_ACCESS_TOKEN" ;
const char * refresh_token = "YOUR_REFRESH_TOKEN" ;
const char * client_id = "YOUR_CLIENT_ID" ;
const char * client_secret = "YOUR_CLIENT_SECRET" ;
const char * refresh_token_endpoint = "https://oauth2.googleapis.com/token" ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (1000 );
Serial.println ("WiFiに接続中..." );
}
Serial.println ("WiFiに接続完了" );
Serial.println (WiFi.localIP ());
refreshAccessToken ();
}
void loop () {
}
void refreshAccessToken () {
HTTPClient http;
http.begin (refresh_token_endpoint);
String postData = "client_id=" + String (client_id) + "&client_secret=" + String (client_secret) + "&refresh_token=" + String (refresh_token) + "&grant_type=refresh_token" ;
http.addHeader ("Content-Type" , "application/x-www-form-urlencoded" );
int httpResponseCode = http.POST (postData);
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
Serial.print ("HTTP/1.1 " );
Serial.println (httpResponseCode);
Serial.println (response);
DynamicJsonDocument doc (1024 );
deserializeJson (doc, response);
String new_access_token = doc["access_token" ].as<String>();
new_access_token.toCharArray (access_token, sizeof (access_token));
if (strlen (access_token) > 0 ) {
Serial.println ("New Access Token: " + String (access_token));
} else {
Serial.println ("Failed to get access token." );
}
} else {
Serial.print ("HTTPリクエストでエラーが発生しました: HTTP/1.1 " );
Serial.println (httpResponseCode);
String errorResponse = http.getString ();
if (errorResponse.length () > 0 ) {
Serial.println ("エラーレスポンス: " );
Serial.println (errorResponse);
}
}
http.end ();
}
成功レスポンスの例
{
"access_token": "ya29.a0ARrdaM8pYcN7O...d6FNdT5aGv1a6Q",
"expires_in": 3599,
"scope": "https://www.googleapis.com/auth/script.external_request",
"token_type": "Bearer"
}
テストコード作成 Ver.1
API 呼び出しテストとリフレッシュトーク ンテストを終え,今度は定期的にAPI 呼び出し処理を行ってセルの値を監視し,TRUEになったときにWOL するテストコードを作成してみます
実装方針
ESP32に保存する情報: アクセストーク ンとリフレッシュトーク ン,Oauth認証 情報
アクセストーク ンの管理: 有効期限が切れたら(正常なレスポンスではなかったら)リフレッシュトーク ンを使って新しいアクセストーク ンを取得
API 呼び出しの流れ: 定期的にアクセストーク ンを使ってApps Script API を呼び出し,セルの値を取得。TRUEになっていればPCを起動
gas-eps32-wol-v1.ino
#include <WiFi.h>
#include <ArduinoJson.h>
#include <WakeOnLan.h>
#include <HTTPClient.h>
WiFiUDP UDP;
WakeOnLan WOL (UDP);
const char * ssid = "SSID" ;
const char * password = "PASSWORD" ;
char access_token[256 ] = "YOUR_ACCESS_TOKEN" ;
const char * refresh_token = "YOUR_REFRESH_TOKEN" ;
const char * client_id = "YOUR_CLIENT_ID" ;
const char * client_secret = "YOUR_CLIENT_SECRET" ;
const char * api_url = "https://script.googleapis.com/v1/scripts/SCRIPT_ID:run" ;
const char * refresh_token_endpoint = "https://oauth2.googleapis.com/token" ;
const char * pcMacAddress = "00:11:22:33:44:55" ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (1000 );
Serial.println ("WiFiに接続中..." );
}
Serial.println ("WiFiに接続完了" );
Serial.println (WiFi.localIP ());
}
void loop () {
if (!getGASAPI ()) {
refreshAccessToken ();
getGASAPI ();
}
delay (2 * 60 * 1000 );
}
bool getGASAPI () {
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
DynamicJsonDocument doc (1024 );
deserializeJson (doc, response);
bool value = doc["response" ]["result" ].as<bool >();
if (value) {
Serial.println ("PCにマジックパケットを送信します..." );
WOL.sendMagicPacket (pcMacAddress);
delay (1000 );
}
return true ;
} else {
return false ;
}
http.end ();
}
当初はセルの値がTRUEになればすぐにWOL を実行すれば良いと考えていました。
セルの値 getCellValue()
操作
TRUE
WOL 実行
FALSE
何もしない
しかし,いくつかの問題点が見えてきました。
問題点と解決策:其の1
問題点:PCをシャットダウンしてもESP32がAPI にリクエス トを送り続け,返り値(セルの値)がTRUEのままなのでPCがすぐに再起動してしまいます。
解決策:これを防ぐために,まずPCの状態を確認し,PCがオフの場合はセルの値をFALSEに更新します。次回ESP32がAPI の返り値(セルの値)を受け取るときFALSEが返されるためPCは再起動しません。これによってPCが意図せず再起動し続ける問題を解消できます。
真理値表に書いてみると以下のようになります。
PCの状態
セルの値 getCellValue()
セルの値更新 setCellValue(value )
操作
オン
TRUE
TRUE
WOL 実行|
オン
FALSE
TRUE
何もしない
オフ
TRUE
FALSE
WOL 実行
オフ
FALSE
FALSE
何もしない
問題点と解決策:其の2
問題点:もう一つの問題は,ESP32を常に稼働させるのは良いのですが,自宅にいるときはESP32を稼働させる必要がないためコンセントから抜くこともあるでしょう。しかし,外出前にESP32を電源に接続した場合,もしAPI から返されるセルの値がTRUEだった場合,PCが意図せず起動してしまうことになります。
解決策:これを防ぐためにESP32が起動した際にPCがオフの状態である場合は,前回のセルの状態を記録しておく変数(初期値はTRUE)を使います。。ESP32が起動し,PCがオフの状態でAPI から返されたセルの値がTRUEかつ前回の状態を記録しておいた変数がTRUEだった場合,それを「意図しない起動」と判断しPCがオフになっていると判断します。そのあとセルの値をFALSEに更新し前回の状態を記録する変数もFALSEにします。
こうすることで,PCがオフの状態でESP32が2回目のループに入ったとき,セルの値はFALSEのままになります。そのため,ユーザが手動でセルの値をTRUEに変更した場合,ESP32はそれを検知し,これを「ユーザが意図的に変更した」と判断してWOL を実行します。
PCの状態
セルの値 getCellValue()
前回の値
セルの値更新 setCellValue(value )
操作
オン
TRUE
TRUE
TRUE
何もしない
オン
TRUE
FALSE
TRUE
何もしない
オン
FALSE
TRUE
TRUE
何もしない
オン
FALSE
FALSE
TRUE
何もしない
オフ
TRUE
TRUE
FALSE
何もしない
オフ
TRUE
FALSE
FALSE
WOL 実行
オフ
FALSE
TRUE
FALSE
何もしない
オフ
FALSE
FALSE
FALSE
何もしない
このように「ユーザが手動でセルの値をTRUEに変更した場合」にのみPCを起動できる仕組みが完成します。
出てきた解決策を何を使ったら実装できるか,まとめると以下のようになります。
問題点と解決策:其の1で出た「PCの状態を確認する」という解決策は,ESP32からPCにPing を送信することで実装できます。
問題点と解決策:其の2で出た「前回の状態を記録しておく変数」を使う解決策は,previousValueというbool型の変数を用意して実装します。
また,これらを考慮して再度真理値表を見直すと無駄な処理があったため排除しておきます。
PCオンのときはTRUEとみなすため,セルの値や前回の値を確認する必要はない
PCがオフでセルの値がFALSEのときは,ユーザがPCをオンにすることを望んでいないため前回の値を確認する必要はない
PCの状態前回の値Ping
セルの値(ユーザの意図) getCellValue()
前回の値 previousValue
セルの値更新 setCellValue(value )
操作
オン
TRUE
TRUE
TRUE
何もしない
オン
TRUE
FALSE
TRUE
何もしない
オン
FALSE
TRUE
TRUE
何もしない
オン
FALSE
FALSE
TRUE
何もしない
オフ
TRUE(意図しない)
TRUE
FALSE
何もしない
オフ
TRUE (ユーザの操作)
FALSE
TRUE
WOL 実行
オフ
FALSE(望んでいない)
TRUE
FALSE
何もしない
オフ
FALSE (望んでいない)
FALSE
FALSE
何もしない
このようにすることで条件分岐の無駄な処理を減らすことができます。
結果として,4つの分岐だけで十分となりました。
PCの状態Ping
セルの値 getCellValue()
前回の値 previousValue
セルの値更新 setCellValue(value )
操作
オン
取得しない
取得しない
TRUE
何もしない
オフ
TRUE
TRUE
FALSE
何もしない
オフ
TRUE
FALSE
TRUE
WOL 実行
オフ
FALSE
取得しない
FALSE
何もしない
ここまでできたところで,再度テストコードを作成し実行しました。
テストコード作成 Ver.2
Ping 送信: Ping 送信してPCの状態を監視する関数を作成
セルの値更新:セルの値を更新する関数を作成
前回の値: previousValueというbool型の変数(初期値はTRUE)
gas-eps32-wol-v2.ino
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WakeOnLan.h>
#include <ESPping.h>
WiFiUDP UDP;
WakeOnLan WOL (UDP);
const char * ssid = "SSID" ;
const char * password = "PASSWORD" ;
char access_token[256 ] = "YOUR_ACCESS_TOKEN" ;
const char * refresh_token = "YOUR_REFRESH_TOKEN" ;
const char * client_id = "YOUR_CLIENT_ID" ;
const char * client_secret = "YOUR_CLIENT_SECRET" ;
const char * api_url = "https://script.googleapis.com/v1/scripts/SCRIPT_ID:run" ;
const char * refresh_token_endpoint = "https://oauth2.googleapis.com/token" ;
bool previousValue = true ;
const char * pcMacAddress = "00:11:22:33:44:55" ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (1000 );
Serial.println ("." );
}
Serial.println ("WiFiに接続完了" );
}
void loop () {
bool isPcOn = checkPcStatus ();
if (isPcOn) {
if (!updateGASAPI ("TRUE" )) {
Serial.println ("アクセストークンが無効のため再度リフレッシュします..." );
refreshAccessToken ();
updateGASAPI ("TRUE" );
}
previousValue = true ;
}else {
Serial.println ("Apps Script APIを実行します..." );
if (!getGASAPI ()) {
Serial.println ("アクセストークンが無効のため再度リフレッシュします..." );
refreshAccessToken ();
getGASAPI ();
}
}
delay (2 * 60 * 1000 );
}
bool checkPcStatus () {
Serial.print ("Pingを送信中..." );
bool isOnline = Ping.ping (pcIP);
if (isOnline) {
Serial.println ("PCはオンラインです。" );
return true ;
} else {
Serial.println ("PCはオフラインまたは到達不能です。" );
return false ;
}
}
bool getGASAPI () {
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
DynamicJsonDocument doc (1024 );
deserializeJson (doc, response);
bool value = doc["response" ]["result" ].as<bool >();
if (value) {
Serial.println ("PCにマジックパケットを送信します..." );
WOL.sendMagicPacket (pcMacAddress);
delay (1000 );
}
return true ;
} else {
return false ;
}
http.end ();
}
bool updateGASAPI (String status) {
if (httpResponseCode == HTTP_CODE_OK) {
String response = http.getString ();
StaticJsonDocument<256 > doc;
deserializeJson (doc, response);
const char * message = doc["response" ]["result" ]["message" ];
Serial.print ("スプレッドシートの更新結果: " );
Serial.println (message);
return true ;
} else {
return false ;
}
http.end ();
}
void refreshAccessToken () {
}
しかし,ここで新たに3つ目の問題点が見つかりました。
問題点と解決策:其の3
問題点:loop関数でgetGASAPI()関数を実行し,delay(260 1000)を実行して2分間の遅延を入れていたにもかかわらず,ESP32が発熱して本体が非常に熱くなってしまいました。
解決策:発熱を抑えるためにディープスリープという機能を使うことができます。この機能は設定した時間だけESP32の動作をスリープ状態にするものです。これを使用することで発熱を抑えることができると考えました。ディープスリープモードから復帰した際には,プログラムが再び最初から実行されます。これによりloop()関数は不要となり,実質的にスリープを使ったループ処理が可能となります。
しかし,ここでも新たな問題が発生しました…。
問題点と解決策:其の4
問題点:ディープスリープモードから復帰した際に前回の値が保存されず,アクセストーク ンもスリープから復帰するたびに消えてしまいます。
解決策:この問題を解決するためには,RTCメモリを使用することで変数の値をスリープモード中も保持できます。RTCメモリは,ESP32のディープスリープから復帰してもデータが保持されるためアクセストーク ンや前回の値を保存しておくことが可能です。これにより,ディープスリープモードから復帰した後も前回の状態を引き継いで処理を行うことができます。
これらの問題を踏まえて再度テストコードを修正してようやく最終的なプログラムが完成しました。
最終的なプログラムについてはこのブログの目次のプログラムの作成>スケッチにプログラムを載せています。
gas-eps32-wol-v3.ino
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WakeOnLan.h>
#include <ESPping.h>
const char * pcMacAddress = "00:11:22:33:44:55" ;
const IPAddress pcIP (192 , 168 , 0 , 120 );
RTC_DATA_ATTR char access_token[256 ] = "" ;
RTC_DATA_ATTR bool previousValue = true ;
const uint64_t sleepIntervalOn = 15 * 60 * 1000000 ;
const uint64_t sleepIntervalOff = 5 * 60 * 1000000 ;
void setup () {
Serial.begin (115200 );
WiFi.begin (ssid, password);
while (WiFi.status () != WL_CONNECTED) {
delay (500 );
Serial.print ("." );
}
Serial.println (" \n WiFiに接続しました:" );
if (strlen (access_token) == 0 ) {
Serial.println ("アクセストークンが空のため再度リフレッシュします..." );
refreshAccessToken ();
return false ;
}
bool isPcOn = checkPcStatus ();
if (isPcOn) {
if (!updateGASAPI ("TRUE" )) {
Serial.println ("アクセストークンが無効のため再度リフレッシュします..." );
refreshAccessToken ();
updateGASAPI ("TRUE" );
}
previousValue = true ;
}else {
Serial.println ("Apps Script APIを実行します..." );
if (!getGASAPI ()) {
Serial.println ("アクセストークンが無効のため再度リフレッシュします..." );
refreshAccessToken ();
getGASAPI ();
}
}
if (isPcOn) {
Serial.println ("PCはオンラインです。15分間スリープします..." );
esp_sleep_enable_timer_wakeup (sleepIntervalOn);
} else {
Serial.println ("PCはオフラインです。5分間スリープします..." );
esp_sleep_enable_timer_wakeup (sleepIntervalOff);
}
WiFi.disconnect ();
Serial.println ("WiFi切断成功" );
esp_deep_sleep_start ();
}
void loop () {
}
bool checkPcStatus () {
}
bool getGASAPI () {
}
bool updateGASAPI (String status) {
}
void refreshAccessToken () {
}
まとめ
本シリーズは,やや長く冗長な部分もあったかもしれません。非常に分かりにくい箇所があった点についてお詫び申し上げます。
苦労した点と工夫した点
今回のプロジェクトではGoogle のAPI 仕様の理解に最も苦労しました。公式ドキュメントはガバガバ翻訳で重要な部分が端折られているため,細かい仕様を理解するまで時間がかかりました。
しかし,条件分岐の整理にあたってアクティビティ図や真理値表を作成したことで,後半のプログラム作成はスムーズに進行できました。
今後の改良ポイント
今後は,よりセキュアな通信を実現するためにHTTPからHTTPS へ切り替える。また,スマホ でAppSheetの値を変更する際にパスワード認証を導入することでセキュリティ面でも強化を図りたいと考えています。
さらに,このプロジェクトについてのクイックスタートガイドを作成したいとも考えています。加えて,複数のPCを遠隔でWOL 起動したり,逆にシャットダウンする機能も追加したりしたいです。
今回の成果と次回の予定
今回のプロジェクトでは,ESP32を使って外出先からセキュアにWOL を実行することができました。GASとESP32の連携により遠隔操作の可能性を広げることができたのは大きな収穫です。
また,ESP32というマイコン を初めて使ってみて,その多様な可能性に気づくことができました。次回は,エアコンを外出先から操作できるようにしてみたいと考えています。さらに,GPS 機能を使って自宅圏内に入ったら自動でエアコンをつけるという仕組みも導入してみるのも面白いかもしれません。
現在,スイッチボットやNature Remo(ネイチャーリモ)など,赤外線通信を利用したリモコンを扱うIoT製品が数多く出ていますが,自作することで低予算でできるので,今後も趣味としてIoTを楽しんでいきたいと思っています。