孤独クラフト / Solo Craft

引きこもり非エンジニアの製作日記

赤外線リモコンを作ってみる #2

はじめに

前回,ESP32とIRremoteライブラリを使ってテレビリモコンの赤外線信号を送受信することができました。今回は,さらに一歩進めて赤外線のデータ長がテレビリモコンよりも長いエアコンのリモコンを解析し,その信号を送信してみようと思います。

実装準備

実装方針や準備するもの,実装環境に関しては,前回の投稿と特に変わらずエアコン制御用にスケッチを少し変更するだけです。同じ手順で進めます。ただし,エアコンのリモコンは特にデータ長が長いため赤外線信号の解析には少し手間がかかることがあります。

1. 実装方針

リモコンの作成には「受信して解析→送信する」の流れで進めていきます。

受信する

  1. 受信モジュールの回路を組み立てESP32にプログラムを書き込みます。
  2. リモコンのボタンを受信モジュールに向けて信号を送信します。
  3. シリアルモニタで解析されたデータを確認します。

送信する

  1. 赤外線LEDとトランジスタを使って回路を組み,ESP32にプログラムを書き込みます。
  2. プログラムを実行して送信先のデバイスが動作するか確認します。

これでリモコンの信号を受信して解析し,送信するという流れで実装していきます。

2. 準備するもの

3. 回路作成

受信する用回路

受信用回路

送信する用回路

受信用回路

4. 実装環境

Arduino IDE やESP32のセットアップ方法については以前のブログを参考にしてください。
yhotta240.hatenablog.com

ここまでは前回と同様です

yhotta240.hatenablog.com

赤外線を受信

エアコンのリモコンを受信する前に

エアコンのリモコンを解析する前に,まずは様々なリモコンの赤外線信号をIRremoteライブラリで受信した際の出力結果を確認してみます。リモコンの種類によって信号のフォーマットやデータ長が異なるので出力結果がどのように表示されるかを把握しておくとエアコンのリモコンを解析する際に役立ちます。

ここでいくつかのリモコンの出力例を見てエアコンの受信用にプログラムを変更していきます。

前回作成した受信用のスケッチ:

#include <IRremote.hpp>  // IRremoteライブラリ
#define IR_RECEIVE_PIN 13 // GPIOピン番号を定義

void setup() {
  Serial.begin(115200);  // シリアル通信の初期化
  IrReceiver.begin(IR_RECEIVE_PIN, DISABLE_LED_FEEDBACK);  // 赤外線受信の初期化
}

void loop() {
  if (IrReceiver.decode()) {  // 信号を受信したら
    Serial.print("Received Raw Data: ");
    Serial.println(IrReceiver.decodedIRData.decodedRawData, HEX);  // 生のデータを16進数で表示

    IrReceiver.printIRResultShort(&Serial);  // 受信したデータの簡潔な概要を表示
    IrReceiver.printIRSendUsage(&Serial);    // 受信した信号を送信するためのコードを表示

    IrReceiver.resume();  // 次の信号を受信できるようにする
  }
}

テレビリモコン

前回試したテレビリモコンの電源ボタン受信結果
電源ボタン:

Received Raw Data: F00F6380
Protocol=NEC Address=0x6380 Command=0xF Raw-Data=0xF00F6380 32 bits LSB first
Send with: IrSender.sendNEC(0x6380, 0xF, <numberOfRepeats>);
シーラングライトリモコン

次にシーラングライトリモコンを見ていきます。

IMG_5844.JPG

全灯:

Received Raw Data: 3D3409522C
Protocol=PulseDistance Raw-Data=0x3D3409522C 40 bits LSB first
Send on a 32 bit platform with: IrSender.sendPulseDistanceWidth(38, 3500, 1750, 500, 1250, 500, 400, 0x3D3409522C, 40, PROTOCOL_IS_LSB_FIRST, <RepeatPeriodMillis>, <numberOfRepeats>);

消灯:

Received Raw Data: 3E3709522C
Protocol=PulseDistance Raw-Data=0x3E3709522C 40 bits LSB first
Send on a 32 bit platform with: IrSender.sendPulseDistanceWidth(38, 3550, 1700, 500, 1250, 500, 350, 0x3E3709522C, 40, PROTOCOL_IS_LSB_FIRST, <RepeatPeriodMillis>, <numberOfRepeats>);

Protocol=PulseDistance メッセージが表示された場合,そのプロトコルに従った信号が受信されたことを示します。
その後に続くSend on a 32 bit platform with:の部分は,そのまま送信用のコードとして利用できます。このコードを使えば赤外線LEDを通じて同じ信号を送信できるようになります。

エアコン用リモコン

最後にエアコンのリモコンを見ていきます。 IMG_5851.JPG

冷房:

Received Raw Data: 0
Protocol=UNKNOWN Hash=0x0 1 bits (incl. gap and start) received

あれ?エアコンのリモコンを受信しているはずですがProtocol=UNKNOWNとなっていますね。そして,通常表示されるはずのSend with:も出てきません。

IRremoteライブラリによるとProtocol=UNKNOWNのメッセージが表示された場合,以下の可能性があります:

  • プロトコルのデコードに問題がある
  • 未サポートのプロトコルである
  • 赤外線信号が弱く,受信に問題がある
  • 他の光源の影響を受けている

今回の場合,プロトコルのデコードに問題があるかそもそもサポートされていないプロトコルである可能性があります。特にエアコンのリモコンはテレビリモコンなどと比べて非常に長いデータ(多くの場合,750ビット程度)を送信するため通常の設定では対応しきれないことがあります。

そこで,RAM使用量に対応するためRAW_BUFFER_LENGTH750以上くらいに増やしてみます。これによって長い赤外線信号を受信できる範囲が広がり信号の解析ができるかもしれません。

受信用スケッチ修正(エアコン用)

エアコンのリモコンに対応した解析結果を得るためにスケッチを以下のように修正します。 まず,RAM使用量に対応させるためにRAW_BUFFER_LENGTHを余裕をもって900に設定します。 また,受信したデータの送信方法を示してくれるSend with:が表示されない場合に備えて,RAWデータ(生のタイミングデータ)を確認できるようにスケッチを変更します。

if (IrReceiver.decode()) {} のブロック内に、以下のコードを追加することでRAWデータを表示します。

IrReceiver.printIRResultRawFormatted(&Serial, true);

さらに,受信したRAWデータをC言語形式のuint16_t配列として確認するためには,次のコードを追加します:

IrReceiver.compensateAndPrintIRResultAsCArray(&Serial, true);

これにより受信した信号のRAWデータをuint16_t配列として出力し,そのまま送信に使用することができます。

修正後の受信用スケッチ:

#define RAW_BUFFER_LENGTH 900
#include <IRremote.hpp>  // IRremoteライブラリ
#define IR_RECEIVE_PIN 13 // GPIOピン番号を定義

void setup() {
  Serial.begin(115200);  // シリアル通信の初期化
  IrReceiver.begin(IR_RECEIVE_PIN, DISABLE_LED_FEEDBACK);  // 赤外線受信の初期化
}

void loop() {
  if (IrReceiver.decode()) {  // 信号を受信したら
    Serial.print("Received Raw Data: ");
    Serial.println(IrReceiver.decodedIRData.decodedRawData, HEX);  // 生のデータを16進数で表示
    IrReceiver.printIRResultShort(&Serial);  // 受信したデータの簡潔な概要を表示
    IrReceiver.printIRSendUsage(&Serial);    // 受信した信号を送信するためのコードを表示
    IrReceiver.printIRResultRawFormatted(&Serial, true);  // RAWフォーマットで結果を表示
    IrReceiver.compensateAndPrintIRResultAsCArray(&Serial, true);  // uint16_t配列として結果を出力
    IrReceiver.resume();  // 次の信号を受信できるようにする
  }
}

冷房:

Received Raw Data: FF00FF00
Protocol=PulseDistance Repeat gap=51100us Raw-Data=0xFF00FF00 424 bits LSB first
Raw result in microseconds - with leading gap
rawData[851]: 
 -51100
 +3400,-1650
 + 450,-1200 + 500,- 350 + 450,- 400 + 450,- 400
 + 450,- 400 + 450,- 350 + 500,- 350 + 500,- 350
 ・・・{省略}・・・
 + 450,- 400 + 450,- 400 + 450,- 350 + 500,- 350
 + 450,- 400 + 450,- 400 + 450,- 400 + 450,- 400
 + 450
Sum: 532300
uint16_t rawData[851] = {3380,1670, 430,1220, 480,370, 430,420, ・・・{省略}・・・, 430,420, 430,420, 430};  // Protocol=PulseDistance Repeat gap=51100us Raw-Data=0xFF00FF00 424 bits LSB first

rawData[851]:の「851」という数値は,RAW_BUFFER_LENGTHすなわちRAM使用量に対応しています。通常のエアコンのリモコンでは750程度のデータ長が一般的ですが,私のエアコンのリモコンの場合は850を超えるデータが受信されました。このように長い信号に対応するためにはRAW_BUFFER_LENGTHの設定をリモコンのデータ長に合わせて増やす必要があります。

またuint16_t rawData[851]は,受信した赤外線信号をマイクロ秒単位で表現した符号なし16ビット整数の配列です。各要素が赤外線信号のオン/オフの長さを表しており,このデータを使って信号を解析したり再送信したりすることができます。

赤外線を送信

送信用のスケッチ作成(エアコンの冷房)

受信した赤外線信号を送信するためのスケッチを作成します。ここでは,先ほど受信した`rawData[851]のデータをそのまま赤外線モジュールを使って再送信します。

#include <IRremote.hpp>
#define IR_SEND_PIN 4  // MOSFETを制御するGPIOピン(例:GPIO3)
#define PIN_BUTTON_TURN_ON 0

// 冷房
const uint16_t rawData[851] = {3380,1670, 430,1220, 480,370, 430,420, ・・・{省略}・・・, 430,420, 430,420, 430};

void setup() {
  Serial.begin(115200);  // シリアル通信の初期化
  // 赤外線送信モジュールの初期化
  IrSender.begin(IR_SEND_PIN, DISABLE_LED_FEEDBACK, 0);
  pinMode(PIN_BUTTON_TURN_ON, INPUT_PULLUP);
}

void loop() {
  // 受信したデータ送信
  if (digitalRead(PIN_BUTTON_TURN_ON) == LOW) { // bootボタンを押す
    IrSender.sendRaw(rawData, sizeof(rawData)/sizeof(rawData[0]), 38);
    Serial.println("send trun on at " + String(millis()));
  }
  delay(5000);
}
  • const uint16_t rawData[851]として,先ほど受信した赤外線信号データを定義しています。
  • IrSender.sendRaw(rawData, sizeof(rawData) / sizeof(rawData[0]), 38)では,引数にrawDatasizeof(rawData)を使用して配列の長さを計算し,赤外線信号を38kHzのキャリア周波数で送信しています。
  • ESP32のブートボタンをトリガーにしてボタンが押されたときに登録されたrawDataが送信されます。

送信テスト

スケッチを書き込み,ESP32のブートボタンを押して送信テストを行います。シリアルモニタには「send turn on at」といったメッセージが表示され,送信が成功したタイミングがミリ秒単位で確認できます。エアコンの動作確認を行い,正しく赤外線信号が再生されたかを確認します。

無事にエアコンの冷房を起動させることができました。

まとめ

今回は,ESP32とIRremoteライブラリを使用して赤外線信号の受信・解析・再送信を行い,エアコンの制御に成功しました。 今回の重要なポイントは以下の通りです。

  • 赤外線信号の受信RAW_BUFFER_LENGTHを適切に設定することで,長い信号の受信が可能になりました。
  • 赤外線信号の解析:受信したデータをuint16_t配列として出力し、赤外線信号の再送信に利用しました。

この方法により,信号のデータ長が長いリモコンを使った家電の操作や自動化の幅が広がりました。今後はこれらを応用してGoogle Apps Script(GAS)と連携させ,エアコンなどの遠隔操作を目指したいと思います。

参考

asukiaaa.blogspot.com

github.com

docs.sunfounder.com

赤外線リモコンを作ってみる #1

はじめに

前回,ESP32を使ってGoogle Apps Script(GAS)と連携し,外出先から自宅PCを起動する方法を紹介しました。今回はせっかくESP32を手に入れたので自宅にあるリモコンを解析して,ESP32と赤外線LEDを使ってテレビやエアコンを操作してみようと思います。最終的には,前回のブログのようにGASと連携させて外出先から操作できるようにするのが目標です。

赤外線リモコンの作成に関して

ESP32を使った赤外線リモコンの作成に関する情報は多数ありますが,調査してみると IRremoteIRremoteESP8266 というライブラリを使うと実装できるようです。しかし,参考にした記事はどれも複雑だったり,説明が端折られていたりして初心者にはちょっと難しそうでした。 そんな中,唯一参考になりそうな記事を見つけたので,それをもとに材料を集めてみました。 asukiaaa.blogspot.com

上記の記事をまるまる参考にしてモジュールを購入しました。そして自分なりに実際にどんな風に進めていったか紹介していきます。

実装方針

リモコンの作成には,「受信して解析→送信する」の流れで進めていきます。

受信する

  1. 受信モジュールの回路を組み立てESP32にプログラムを書き込みます。
  2. リモコンのボタンを受信モジュールに向けて信号を送信します。
  3. シリアルモニタで解析されたデータを確認します。

送信する

  1. 赤外線LEDとトランジスタを使って回路を組み,ESP32にプログラムを書き込みます。
  2. プログラムを実行して送信先のデバイスが動作するか確認します。

これでリモコンの信号を受信して解析し,送信するという流れで実装していきます。

準備するもの

回路を組むときにちょっとした問題が発生しました。 ブレッドボードにESP32を差し込もうとしたらピンが刺さらないうえにブレッドボード自体がきつきつでした。 秋月電子通商一番安いやつを買ったので仕方ないですね。

運よくジャンパーワイヤを買ってあったので,それを使って回路を組みました。固定はできませんでしたがこれでなんとか動くようにしました。

実装環境

回路作成

最終的にこんな感じの回路になりました。

受信する用回路

受信用回路

送信する用回路

受信用回路

あまり複雑な回路にならないようにしました。

実行環境

補足
最初はIRremoteESP8266 (v2.8.6) ライブラリを使ってやってみましたが,どうもエラーが多発してうまくできませんでした。調べてみるとこのような情報がありました。

ESP32開発ボードのバージョン3.0.0以上を使用している場合、コンパイルプロセス中にエラーが発生することがあります。 この問題は、ボードの新しいバージョンが IRremoteESP8266 ライブラリをサポートしなくなったためです。 この例を正しく実行するには、ESP32ボードのファームウェアバージョンを2.0.17にダウングレードすることをお勧めします。 この例を完了した後、最新バージョンに再度アップグレードしてください。

引用元:
https://docs.sunfounder.com/projects/esp32-starter-kit/ja/latest/arduino/basic_projects/ar_irremote.html

どうやらESP32のバージョンをダウングレードする必要があったみたいです。自分の環境を確認したらやはりESP32開発ボードが3.0.0以上でした。しかし,バージョンを下げると他の開発に影響が出そうなので結局 IRremoteESP8266 ではなく IRremoteを使うことにしました。

Arduino IDE やESP32のセットアップ方法については以前のブログを参考にしてください。
yhotta240.hatenablog.com

赤外線を受信

Arduino IDEを開き,赤外線を受信するスケッチを作成します。 はじめはファイル > スケッチ例 > IRremote > ReceiveDemo というサンプルプログラムを見てみたのですが,初心者にとってはどこをどう変えたらいいのかわからなかったので IRremote のドキュメントを確認してみました。

New features with version 4.x(バージョン 4.x の新機能)というところを参考にしてプログラムを作成していきます。

ドキュメントの「Converting your 2.x program to the 4.x version(2.x プログラムを 4.x バージョンに変換する)」という項目のNew 4.x program:(新しい 4.x プログラム例)で以下のようなプログラムの例があったのでこれをベースにスケッチを作成していきます。

新しい 4.x プログラム例:

#include <IRremote.hpp>
#define IR_RECEIVE_PIN 2

void setup()
{
...
  Serial.begin(115200); // // Establish serial communication
  IrReceiver.begin(IR_RECEIVE_PIN, ENABLE_LED_FEEDBACK); // Start the receiver
}

void loop() {
  if (IrReceiver.decode()) {
      Serial.println(IrReceiver.decodedIRData.decodedRawData, HEX); // Print "old" raw data
      IrReceiver.printIRResultShort(&Serial); // Print complete received data in one line
      IrReceiver.printIRSendUsage(&Serial);   // Print the statement required to send this data
      ...
      IrReceiver.resume(); // Enable receiving of the next value
  }
  ...
}

受信用のスケッチ作成:

#include <IRremote.hpp>  // IRremoteライブラリ
#define IR_RECEIVE_PIN 13 // GPIOピン番号を定義

void setup() {
  Serial.begin(115200);  // シリアル通信の初期化
  IrSender.begin(DISABLE_LED_FEEDBACK);  // 赤外線受信の初期化
}

void loop() {
  if (IrReceiver.decode()) {  // 信号を受信したら
    Serial.print("Received Raw Data: ");
    Serial.println(IrReceiver.decodedIRData.decodedRawData, HEX);  // 生のデータを16進数で表示

    IrReceiver.printIRResultShort(&Serial);  // 受信したデータの簡潔な概要を表示
    IrReceiver.printIRSendUsage(&Serial);    // 受信した信号を送信するためのコードを表示

    IrReceiver.resume();  // 次の信号を受信できるようにする
  }
}

ピンとコメントのところ以外は何も変えていません。

プログラムの解説

プログラムでは,赤外線の信号がちゃんと受信できたかを次のように確認します。

if (IrReceiver.decode()) {
}

このIrReceiver.decode()が「true」を返したらデータは正しく受信されてデコード(解析)されています。

デコードが成功すると,その赤外線データは「IRData」という構造体に保存されてIrReceiver.decodedIRDataとして使えるようになります。

RAWデータ(生の信号データ)にアクセスするには次のように書きます。

auto myRawdata = IrReceiver.decodedIRData.decodedRawData;

すべての解析結果を表示するには次のように書きます。

IrReceiver.printIRResultShort(&Serial);

これで受信した赤外線信号の概要が表示されます。

また,受信したRAWデータ(生のタイミングデータ)を表示したい場合はこちらを使います。

IrReceiver.printIRResultRawFormatted(&Serial, true);

RAWデータは,Arduinoの内部タイマーの動きに影響されるため,受信するたびに少しだけ違う値になることがあります(タイマーの精度の問題です)。しかし,デコードされた値はその小さな違いを自動で補正して正しい信号として解釈されるようになっています。

最後に受信したデータの送信方法を表示したい場合は次のようにします。ここが後ほど重要になってきます。

IrReceiver.printIRSendUsage(&Serial);

参考 github.com

新規のスケッチを作成して完成した受信用のスケッチを貼り付けます。ESP32をPCにつなぎ,Arduino IDEの左上にある書き込みボタンをクリックして書き込み開始します。書き込みが完了するとプログラムが実行されます。

受信テスト:

プログラムが実行されたら任意のリモコンをレシーバに向け,リモコンの調べたいボタンを押します。

今回調べたいリモコンは3つあります。

これらIRレシーバに向けて調べていきます。 まず,テレビのリモコンを例に受信されたデータを見ていきます。

テレビリモコン
* テレビのリモコン(ORION)

電源ボタン:

Received Raw Data: F00F6380
Protocol=NEC Address=0x6380 Command=0xF Raw-Data=0xF00F6380 32 bits LSB first
Send with: IrSender.sendNEC(0x6380, 0xF, <numberOfRepeats>);

1チャンネルボタン:

Received Raw Data: FF006380
Protocol=NEC Address=0x6380 Command=0x0 Raw-Data=0xFF006380 32 bits LSB first
Send with: IrSender.sendNEC(0x6380, 0x0, <numberOfRepeats>);

2チャンネルボタン:

Received Raw Data: FE016380
Protocol=NEC Address=0x6380 Command=0x1 Raw-Data=0xFE016380 32 bits LSB first
Send with: IrSender.sendNEC(0x6380, 0x1, <numberOfRepeats>);

他に3チャンネルや4チャンネルといったボタンをいくつか試してみましたが,このテレビのリモコンには一定の規則性があるようでした。ここでは,Command=の部分が変化していることが分かりました。

赤外線を送信

送信用の回路を組んだ後,ドキュメントをもとに送信用のスケッチ作成を作成します。

受信されたテレビリモコンの電源ボタンの解析結果は以下のようになっていました。

Received Raw Data: F00F6380
Protocol=NEC Address=0x6380 Command=0xF Raw-Data=0xF00F6380 32 bits LSB first
Send with: IrSender.sendNEC(0x6380, 0xF, <numberOfRepeats>);

ここで「Send with」と書かれている部分ですが,これは受信用のスケッチで指定したIrReceiver.printIRSendUsage(&Serial);によって受信したデータをどのように送信するかを表示している箇所です。

つまり,IrSender.sendNEC(0x6380, 0x1D, <numberOfRepeats>);というこのコードをそのまま利用して赤外線を送信できるということです。

<numberOfRepeats>の部分には、送信の際のリピート回数を指定すればOKです。 それを踏まえた上でテレビリモコン電源の送信用スケッチ作成を作成します。

送信用のスケッチ作成(テレビリモコン電源ボタン)

#include <IRremote.hpp>
#define IR_SEND_PIN 4  // MOSFETを制御するGPIOピン

void setup() {
  // 赤外線送信モジュールの初期化
  IrSender.begin(IR_SEND_PIN, DISABLE_LED_FEEDBACK, 0);  
  Serial.begin(115200);  // シリアル通信の初期化
}

void loop() {
  // 受信したデータを再送信
  Serial.println("赤外線送信");
  IrSender.sendNEC(0x6380, 0xF, 0);  
  delay(5000);  // 5秒ごとに信号を送信
}

書き込みが完了したら送信テスト行います。

送信テスト:

赤外線LEDが正しく送信しているかを確認する方法はいくつかあります。

  1. 対象の機器に向けて動作確認
    まず,送信先の機器(テレビやエアコンなど)が期待通りに動作するかを確認します。

  2. スマホで確認
    赤外線LEDの光は肉眼では見えませんが,スマートフォンのカメラを通すと紫色に光っているのが確認できます。もし光が見えなければ回路に問題がある可能性があります。

    • 回路が間違っている
    • 抵抗値が大きすぎる
    • プログラムが正しく動作していない
  3. IRレシーバで確認(任意)
    受信側にIRレシーバを組み込み,送信された信号を受信できるかを確認する方法もあります。

これらの点を確認しながらテストを進めます。

テスト結果

プログラムを実行し,赤外線LEDをテレビに向けたところ5秒間隔で無事に電源がオン・オフすることが確認できました。

まとめ

今回は,ESP32を使って赤外線リモコンを作成し,テレビの操作ができるかを試してみました。リモコンの信号を受信して解析し,それを使って実際にテレビの電源を操作できたのでかなり満足な結果となりました。ライブラリの互換性の問題もありましたが,基本的には受信したデータをそのまま送信するだけで問題なく動作しました。次回は,シーリングライトや解析が難しそうなエアコンのリモコンを試して他の家電も操作できるようにしていきたいと思います。

参考

asukiaaa.blogspot.com

github.com

docs.sunfounder.com

GASとESP32で外出先からPCを起動する【第4部】

はじめに

本シリーズでは,Google Apps Script (GAS) と ESP32 を使って,外出先から自宅のPCをリモートで起動する方法を紹介しています。あくまで個人的な実践経験を共有することが目的のため正確性に欠ける部分があるかもしれませんが,参考にしていただければ幸いです。

前回の振り返り

前回は,Apps Script APIを外部から呼び出すためにOAuth認証と実際にAPIをリクエストしてレスポンスを確認するテストを行いました。今回は,いよいよESP32にHTTPリクエストを送信し,Apps Script APIを呼び出して最終的にPCの電源をオンにするという一連の流れを実現します。

早速プログラムの作成に映りますが,まずは今までの内容を振り返りながら必要なライブラリ,環境設定,プログラムの全体像(UML)を把握しプログラムの作成へと移ります。 最後にはプログラムの解説と作成に至った経緯を書いているので興味があれば読んでください。

環境構築と必要条件

PCのWOL設定

PCが遠隔で起動できるようにPCの設定を変更します。
▼ 以下のサイトを参考にして設定を変更してください。 eizone.info

ESP32の必要条件

ESP32でWake On LANWOL)機能を実現するためには,以下の設定を行いWOLが正しく機能することを確認する必要があります。
▼ 以下の手順を参考にWOLを完了させてください。 yhotta240.hatenablog.com ▼ 要約版 qiita.com

Apps Script APIを利用するための環境構築

  1. Google Sheetsの作成とApps Scriptプロジェクトの作成:
    Google Sheetsを作成し,そこに関連付けるGoogle Apps Scriptプロジェクトを作成します。プロジェクトの設定で実行可能なAPIとしてデプロイします。 yhotta240.hatenablog.com

  2. クライアントアプリの作成:
    Google Sheetsの拡張機能からクライアントアプリとしてAppSheetを作成します。

  3. Google Cloudプロジェクトの作成とOAuth認証の設定:
    Google Cloud Consoleでプロジェクトを作成し,Google Apps Scriptとの連携設定を行います。OAuth 2.0の認証設定を完了しAPIの利用を許可します。その際はAppSheetをクライアントアプリ(承認済みのリダイレクト URI)として設定しておきましょう。

  4. 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プログラムの全体の流れ

  1. Wi-Fiに接続
  2. PCのオンライン状態チェック
  3. PCがオフかつGoogle Sheetsの値がFALSEからTRUEに代わっていた場合のみWOLする
  4. Google Sheetsの値の値を更新

UML

私はエンジニアではないのでUMLの作成については詳しくないですが,以下のようなUMLを作成しました。

クラス図

https://www.plantuml.com/plantuml/png/VPBFYzD05CVl-IiUFRL83rRq452skuW8uXOZUb2HsUcphPjDGcRIYmfcnlM7FIY2kwWdHLTPjGgA8eh_p2EDVuOp-I4feZbDvhtlv_tupegGB9R9bkzmdma1nxt1eHvSSy1yNU1Z5WFZ78MuBCDD32xq3X-v14UXrQeoDiBGXoZ6wIHCn3dc9sYYCaxGkU5Ko1DzxmzGZ17bQRQ5u88duqamiZ1INqd_87ttO8tbwFjdM31QpbljjIosxB7RlNwiOBlGZoBmU3o99FG79oy6xUpcIrAF5iy-BtO-TXoJL6ONmd_dPmzsif-xdRg02mVC0f1l3hWdcKn4k-EMyxemMADqdj__ijYU6M4-Vvu_lDsGZL2QeePj9TDmCZItTGsLRx-cT9VIlVFhfoXzGcf6wQousQVqbZbdT_OChWmre4aqP18hxjIkkcod0sKD5yg1_nB6U2L6CUuNFfwrDZRQ8hrF-Z7fTwHruSChuuDrGtqW_SbsfkwHUc5_rNTItvQYzSuSk-DwyZNZNV4IshQpzOshoAKvMBb-kzepYskkk-gsrek6lZaaik3_j5zltcVpfnLj1OEXUTE_0G00

クラス図

アクティビティ図

アクティビティ図は,プログラムの処理の流れや条件分岐を示す図です。以下に示すアクティビティ図では,ESP32とGASを使ったWake On LANのプロセスを可視化しています。

https://www.plantuml.com/plantuml/png/nLH1QnD15BxFhzXZz-31WvUTWot5dWgMDjfpm4bTMADqDtTt9kgccoe8fj22aX3H99W4b60qvCyyPZSt_u8pyo09sOthhSiUPkPzyxtlVUynktvGFWzgppnY5iG1FWNH1p43SGMYOnOD26FW3JdloPzZIfnJzyw12-4WUVivdLoh0zcub3SVvSikyFTfvzUYVxaJdDUOJObRiNQ0Tu2FWFyst96Yr5ln7S8HX1nuhEXjAtZAgWghp-esOPxFzAvk_iWcblgSRKpoNKlEhoXXrITkXJX7-n0Equj9yYfMWexSwXc4Nr0NAjA09l2-5Y5wmBj6JNn_gSA4R9qJ4_iis6UUzxZirHYMPidXfqMxIKrSSsOXmBzgTZ7xCxicsqreflMsl9XYxapDQ0JQWYV_vGJ2YfMZOmQvtiJavaVI6YEUOlZkFHczpfqEYjr2rpxO23zRovlnQZru-ovbrMRzGTsQIGpKS6cNmf5LEdxqO3aqUhFAlLpeUsXjuRtyfbNAdh_HDVpoUuRm5GwjETWxFDdCSlhmCCjQF3Fu5KzCnWdqCbzJSTM55Ulmvap_OsOHXM-PufBHcwJrZMvv04zAUySbIlpW-Gkogswg1_Cl

アクティビティ図

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>

// WIFI設定
WiFiUDP UDP;
WakeOnLan WOL(UDP);
const char* ssid = "SSID";
const char* password = "PASSWORD";
// OAuth 2.0の設定
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";
// PC情報の設定
const char* pcMacAddress = "AB:CD:EF:12:34:56";  // MACアドレス設定
const IPAddress pcIP(192, 168, 0, 120);          // Pingを送信するPCのIPアドレス
// RTCメモリに保存する変数
RTC_DATA_ATTR char access_token[256] = "";
RTC_DATA_ATTR bool previousValue = true;
// スリープの間隔(ON=15分、OFF=5分)
const uint64_t sleepIntervalOn = 15 * 60 * 1000000;  // 15分
const uint64_t sleepIntervalOff = 5 * 60 * 1000000;  // 5分

void setup() {
  Serial.begin(115200); // シリアル通信を開始
  WiFi.begin(ssid, password); // Wi-Fi接続を開始
  while (WiFi.status() != WL_CONNECTED) { // 接続が完了するまで待機
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFiに接続しました:");
  Serial.print("IPアドレス: ");
  Serial.println(WiFi.localIP()); // ESP32のIPアドレス  

  Serial.print("前回のValue:");
  Serial.println(previousValue ? "true" : "false");
  Serial.print("現在のアクセストークン:");
  Serial.println(String(access_token));
  // アクセストークンを確認
  if (strlen(access_token) == 0) { 
    Serial.println("アクセストークンが空のため再度リフレッシュします...");
    refreshAccessToken();
  }
  // PCの状態を確認
  bool isPcOn = checkPcStatus();
  
  if (isPcOn) { // PCがオンラインの場合
    if (!updateGASAPI("TRUE")) { // FALSEだった場合アクセストークンを取得して更新
      Serial.println("アクセストークンが無効のため再度リフレッシュします...");
      refreshAccessToken(); // 新しいアクセストークンを取得
      updateGASAPI("TRUE"); // セルの値をTRUEに更新
    }
    previousValue = true; // 前回の値をTRUEに更新
  }else{ // PCがオフラインの場合
    Serial.println("APIリクエストを実行します...");
    if (!getGASAPI()) { // FALSEだった場合アクセストークンを取得して更新
      Serial.println("アクセストークンが無効のため再度リフレッシュします...");
      refreshAccessToken();
      getGASAPI();
    }
  }

  WiFi.disconnect(); // Wi-Fiを切断
  Serial.println("Wi-Fi接続を解除しました");
  
  // スリープ時間を設定
  if (isPcOn) {
    Serial.println("PCはオンラインです。15分間スリープします...");
    esp_sleep_enable_timer_wakeup(sleepIntervalOn);  // PCがオンラインの場合,15分スリープ
  } else {
    Serial.println("PCはオフラインです。5分間スリープします...");
    esp_sleep_enable_timer_wakeup(sleepIntervalOff);  // PCがオフラインの場合,5分スリープ
  }

  // ディープスリープモードに入る
  esp_deep_sleep_start();
}

void loop() {
  // loop()は空です。ディープスリープから復帰すると再起動が行われるためsetup()が再実行されます。
}

bool checkPcStatus() { // PCの状態を確認
  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);
    // Serial.println(response);
    // レスポンスからJSONデータをパース
    DynamicJsonDocument doc(1024);
    deserializeJson(doc, response);
    bool value = doc["response"]["result"].as<bool>();

    if (value && !previousValue) {  // セルの値がTRUEに変化した場合
      Serial.println("PCにマジックパケットを送信します...");
      WOL.sendMagicPacket(pcMacAddress);
      delay(1000);
      updateGASAPI("TRUE");
      previousValue = value;              // 前回値を更新 (true)
    } else if (value && previousValue) {  // セルの値が変化していない場合
      Serial.println("前回と値が変わらないため,Google SheetsをFALSEに更新します...");
      delay(1000);
      updateGASAPI("FALSE"); 
      previousValue = !value;  // 前回値を更新 (false)
    } else if (!value) {       // セルの値がFALSEの場合
      Serial.println("PCはシャットダウン状態です。");
      delay(1000);
      updateGASAPI("FALSE");
      previousValue = value;  // 前回値を更新 (false)
    }

    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; // JSONドキュメントの容量を設定
    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);
    // 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)); // 新しいアクセストークンをchar型配列にコピー
    
    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) {
  // 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 プログラムの確認

まずは,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> 

// Wi-Fi設定
WiFiUDP UDP;
WakeOnLan WOL(UDP);
const char* ssid = "SSID";  // ここにWi-FiのSSIDを入力
const char* password = "PASSWORD";  // ここにWi-Fiのパスワードを入力
// MACアドレス設定
const char* pcMacAddress = "AA:BB:CC:DD:EE:FF";  // ここに自宅PCのMACアドレスを入力

void setup() {
  Serial.begin(115200);
  // Wi-Fiに接続
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("WiFiに接続中...");
  }
  Serial.println("WiFiに接続完了");
  Serial.println(WiFi.localIP());

  // マジックパケット送信
  WOL.sendMagicPacket(pcMacAddress);  // MACアドレスを使用してマジックパケットを送信
  Serial.println("Wake on LANパケット送信");
}

void loop() {
  // 何も実行しません
}

簡単にプログラムを説明すると

  1. 最初に必要なライブラリを読み込む
  2. ssidpasswordWi-FiSSIDとPASSWORDを保存
  3. pcMacAddressにPCのMACアドレスを保存
  4. setup関数: プログラムの開始時に一度だけ実行され,Wi-Fiに接続が完了したらPCを起動するためのマジックパケットを送信
  5. loop関数: テストのためここでは何も実行しない

このプログラムでは,ESP32が電源に接続されると一度だけPCを起動する動作を行います。

しかし,外部からいつでもPCを起動できるようにするには,ESP32を「常に稼働させておくこと」と「APIで値を受け取る処理」を組み込む必要があります。そのため,まずは定期的な処理を行う方法を理解し,APIとの通信を定期的に行ってAPIの返り値がTRUEのときにESP32がPCの電源を入れるようにプログラムを変更します。

定期的に処理を行うには?
では,どのようにしてESP32で定期的な処理を行うのでしょうか?
ここでArduino IDEのプログラム仕様について見ていきます。

Arduino IDEのループ処理

Arduino IDEの仕様

Arduino IDEC/C++をベースとしたArduino言語でプログラミングします。 Arduinoプログラムは,主に以下の2つの関数で構成されます。

1. setup() 関数 setup() 関数は,プログラムが始まった際に一度だけ実行されます。ここでは,初期設定やセンサー・ネットワークの接続などプログラム開始時に必要な設定を行います。例えば,ESP32でWi-Fiに接続する処理や初期化が必要なデバイスの設定がここに含まれます。

2. loop() 関数 loop() 関数は,setup() が完了した後に繰り返し実行される部分です。この関数内に書いた処理は,ESP32が動作している限りループし続けます。例えば,センサーの値を定期的に読み取ったり,サーバーに定期的にリクエストを送ったりする処理をloop()関数内に書きます。

void setup() {
  // put your setup code here, to run once:
  // 一度だけ実行するための初期設定コードを置きます
}

void loop() {
  // put your main code here, to run repeatedly:
  // 繰り返し実行するためのメインコードをここに置きます
}

このようにArduino IDEでは,プログラムを簡潔に2つの関数で構成できます。電源が入るとまずsetup()が実行され,続けてloop()で繰り返し処理が行われてデバイスを制御します。

定期的な処理

つまり,ESP32の電源が入っている状態ではloop()関数で定期的な処理を行うことができます。このloop()関数内にAPIで値を受け取る処理を記述すれば,定期的にAPIから値を取得し必要に応じてPC起動の制御ができます。
これらの基本を押さえた上で,次にESP32からAPIで値を受け取る処理を実装するにはどうしたらよいのでしょうか?

前回行った「APIを叩いてみる」を見てみると良さそうですね

APIで値を受け取る処理

APIテストの振り返り

前回,BashcURLを使って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呼び出しテスト

  • Wi-Fi接続の確認:SSIDとパスワードを使ってWi-Fiに接続
  • APIの呼び出し:getGASAPI()関数を呼び出し,Apps Script APIにアクセス。アクセストークンを使って認証を行いセルの値を取得するためのリクエストを送信。
#include <WiFi.h>
#include <HTTPClient.h>  // HTTPリクエストを送信するためのライブラリ
#include <ArduinoJson.h> // JSONデータを扱うためのライブラリ

// Wi-Fi設定
const char* ssid = "SSID";               // Wi-FiのSSID
const char* password = "PASSWORD";       // Wi-Fiのパスワード

// OAuth 2.0の設定
String access_token = "YOUR_ACCESS_TOKEN";    // 取得したアクセストークン
String api_url = "https://script.googleapis.com/v1/scripts/SCRIPT_ID:run";  // Apps Script APIのURL

void setup() {
  Serial.begin(115200);  // シリアル通信を開始
  WiFi.begin(ssid, password);  // Wi-Fi接続を開始
  while (WiFi.status() != WL_CONNECTED) {  // 接続が完了するまで待機
    delay(1000);
    Serial.println("WiFiに接続中...");
  }
  Serial.println("WiFiに接続完了");
  Serial.println("IPアドレス: ");
  Serial.println(WiFi.localIP());  // 接続完了時にESP32のIPアドレスを表示         // Wi-Fi接続を行う関数を呼び出す

  getGASAPI();  // APIを呼び出してセルの値を取得
}

void loop() {
  // loop内は空のまま
}

// Apps Script APIからセルの値を取得する関数
void getGASAPI() {
  HTTPClient http;  // HTTPリクエスト用オブジェクトを作成
  http.begin(api_url);  // APIのURLを設定

  // HTTPリクエストのヘッダーを設定
  http.addHeader("Authorization", "Bearer " + String(access_token));  // OAuth 2.0のアクセストークンをヘッダーに追加
  http.addHeader("Content-Type", "application/json");  // コンテンツタイプをJSONに設定

  // APIに送信するJSONペイロードを作成
  String jsonPayload = "{\"function\": \"getCellValue\"}";

  // POSTリクエストを送信し、HTTPレスポンスコードを取得
  int httpResponseCode = http.POST(jsonPayload);

  // HTTPレスポンスが正常(200 OK)であるか確認
  if (httpResponseCode == HTTP_CODE_OK) {
    String response = http.getString();  // APIからのレスポンスを取得
    Serial.print("HTTP/1.1 ");
    Serial.println(httpResponseCode);
    Serial.println("APIのレスポンス: ");
    Serial.println(response);

    // レスポンスのJSONデータをパース
    DynamicJsonDocument doc(1024);  // JSON解析のためのバッファを用意
    DeserializationError error = deserializeJson(doc, response);  // JSONをパースしてdocに保存

    // JSON解析に成功したか確認
    if (!error) {
      String value = doc["response"]["result"].as<String>();  // "result"フィールドから値を取得
      Serial.print("セルの値: ");
      Serial.println(value);  // セルの値を表示
    } else {
      Serial.println("JSONの解析に失敗しました");
    }
  } else {
    // HTTPリクエストが失敗した場合のエラーメッセージを表示
    Serial.print("HTTPリクエストでエラーが発生しました: HTTP/1.1 ");
    Serial.println(httpResponseCode);
    String errorResponse = http.getString();  // エラーメッセージを取得
    Serial.println("エラーレスポンス: ");
    Serial.println(errorResponse);  // エラーレスポンスを表示
  }

  http.end();  // HTTP接続を終了
}

テストの成功例

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>

// Wi-Fi設定
const char* ssid = "SSID";
const char* password = "PASSWORD";

// OAuth 2.0の設定
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);

  // POSTデータの作成
  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>();

    // 新しいアクセストークンをchar型配列にコピー
    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>

// Wi-Fi設定
WiFiUDP UDP;
WakeOnLan WOL(UDP);
const char* ssid = "SSID";
const char* password = "PASSWORD";

// OAuth 2.0の設定
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";

// PCのMACアドレス
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() {
  // Apps Script APIから値を取得する
  if (!getGASAPI()) {      // 失敗した場合
    refreshAccessToken();  // トークンのリフレッシュ
    getGASAPI();
  }

  delay(2 * 60 * 1000);  // 2分ごとに実行
}

// Apps Script API の値を受け取る関数
bool getGASAPI() {
  // 省略
  if (httpResponseCode == HTTP_CODE_OK) {
    String response = http.getString();
    // レスポンスからJSONデータをパース
    DynamicJsonDocument doc(1024);
    deserializeJson(doc, response);
    bool value = doc["response"]["result"].as<bool>();
    
    if (value) {  // スプレッドシートの値がTRUEに変化した場合
      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>
// Wi-Fi設定
WiFiUDP UDP;
WakeOnLan WOL(UDP);
const char* ssid = "SSID";
const char* password = "PASSWORD";

// OAuth 2.0の設定
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;//前回の値
// PCのMACアドレス
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();
  // Apps Script APIから値を取得する
  if (isPcOn) { // PCがオンラインの場合
    if (!updateGASAPI("TRUE")) { // FALSEだった場合アクセストークンを取得して更新
      Serial.println("アクセストークンが無効のため再度リフレッシュします...");
      refreshAccessToken();
      updateGASAPI("TRUE");
    }
    previousValue = true;
  }else{ // PCがオフラインの場合
    Serial.println("Apps Script APIを実行します...");
    if (!getGASAPI()) {
      Serial.println("アクセストークンが無効のため再度リフレッシュします...");
      refreshAccessToken();
      getGASAPI();
    }
  }
  delay(2 * 60 * 1000);  // 2分ごとに実行
}

bool checkPcStatus() {
  Serial.print("Pingを送信中...");
  bool isOnline = Ping.ping(pcIP);

  if (isOnline) {
    Serial.println("PCはオンラインです。");
    return true;
  } else {
    Serial.println("PCはオフラインまたは到達不能です。");
    return false;
  }
}

// Apps Script API の値を受け取る関数
bool getGASAPI() {
  // 省略
  if (httpResponseCode == HTTP_CODE_OK) {
    String response = http.getString();
    // レスポンスからJSONデータをパース
    DynamicJsonDocument doc(1024);
    deserializeJson(doc, response);
    bool value = doc["response"]["result"].as<bool>();
    
    if (value) {  // スプレッドシートの値がTRUEに変化した場合
      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();
    // JSONドキュメントの容量を設定(必要に応じてサイズを調整)
    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(2601000)を実行して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>

// WIFI設定(省略)
// OAuth 2.0の設定(省略)

// PC情報の設定
const char* pcMacAddress = "00:11:22:33:44:55"; // MACアドレス設定
const IPAddress pcIP(192, 168, 0, 120);          // Pingを送信するPCのIPアドレス
// RTCメモリに保存する変数
RTC_DATA_ATTR char access_token[256] = "";
RTC_DATA_ATTR bool previousValue = true;
// スリープの間隔(ON=15分、OFF=5分)
const uint64_t sleepIntervalOn = 15 * 60 * 1000000;  // 15分
const uint64_t sleepIntervalOff = 5 * 60 * 1000000;  // 5分

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, password);//WiFi
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFiに接続しました:");
  // アクセストークンを確認
  if (strlen(access_token) == 0) {
    Serial.println("アクセストークンが空のため再度リフレッシュします...");
    refreshAccessToken();
    return false;
  }
  // PCの状態を確認
  bool isPcOn = checkPcStatus();
  
  if (isPcOn) { // PCがオンラインの場合
    if (!updateGASAPI("TRUE")) { // FALSEだった場合アクセストークンを取得して更新
      Serial.println("アクセストークンが無効のため再度リフレッシュします...");
      refreshAccessToken();
      updateGASAPI("TRUE");
    }
    previousValue = true;
  }else{ // PCがオフラインの場合
    Serial.println("Apps Script APIを実行します...");
    if (!getGASAPI()) {
      Serial.println("アクセストークンが無効のため再度リフレッシュします...");
      refreshAccessToken();
      getGASAPI();
    }
  }

  // スリープ時間を設定
  if (isPcOn) {
    Serial.println("PCはオンラインです。15分間スリープします...");
    esp_sleep_enable_timer_wakeup(sleepIntervalOn);  // PCがオンラインの場合,15分スリープ
  } else {
    Serial.println("PCはオフラインです。5分間スリープします...");
    esp_sleep_enable_timer_wakeup(sleepIntervalOff);  // PCがオフラインの場合,5分スリープ
  }

  WiFi.disconnect(); // Wi-Fiを切断
  Serial.println("WiFi切断成功"); // 切断成功メッセージを表示
  // スリープモードに入る
  esp_deep_sleep_start();
}
void loop() {
  // `loop()`は空です。スリープから復帰すると再起動が行われるため`setup()`が再実行されます。
}
bool checkPcStatus() {
  //省略
}
bool getGASAPI() {
  // 省略
}
bool updateGASAPI(String status) {
// 省略
}
void refreshAccessToken() {
 // 省略
}

まとめ

本シリーズは,やや長く冗長な部分もあったかもしれません。非常に分かりにくい箇所があった点についてお詫び申し上げます。

苦労した点と工夫した点

今回のプロジェクトではGoogleAPI仕様の理解に最も苦労しました。公式ドキュメントはガバガバ翻訳で重要な部分が端折られているため,細かい仕様を理解するまで時間がかかりました。
しかし,条件分岐の整理にあたってアクティビティ図や真理値表を作成したことで,後半のプログラム作成はスムーズに進行できました。

今後の改良ポイント

今後は,よりセキュアな通信を実現するためにHTTPからHTTPSへ切り替える。また,スマホでAppSheetの値を変更する際にパスワード認証を導入することでセキュリティ面でも強化を図りたいと考えています。
さらに,このプロジェクトについてのクイックスタートガイドを作成したいとも考えています。加えて,複数のPCを遠隔でWOL起動したり,逆にシャットダウンする機能も追加したりしたいです。

今回の成果と次回の予定

今回のプロジェクトでは,ESP32を使って外出先からセキュアにWOLを実行することができました。GASとESP32の連携により遠隔操作の可能性を広げることができたのは大きな収穫です。

また,ESP32というマイコンを初めて使ってみて,その多様な可能性に気づくことができました。次回は,エアコンを外出先から操作できるようにしてみたいと考えています。さらに,GPS機能を使って自宅圏内に入ったら自動でエアコンをつけるという仕組みも導入してみるのも面白いかもしれません。

現在,スイッチボットやNature Remo(ネイチャーリモ)など,赤外線通信を利用したリモコンを扱うIoT製品が数多く出ていますが,自作することで低予算でできるので,今後も趣味としてIoTを楽しんでいきたいと思っています。

GASとESP32で外出先からPCを起動する【第3部】

はじめに

本シリーズでは,Google Apps Script (GAS) と ESP32 を使って,外出先から自宅のPCをリモートで起動する方法を紹介しています。あくまで個人的な実践経験を共有することが目的のため正確性に欠ける部分があるかもしれませんが,参考にしていただければ幸いです。

前回の振り返り

▼ 前回の投稿 yhotta240.hatenablog.com

前回は,Google Apps Script(以下GAS)を使ってプロジェクトを作成し,APIをデプロイするところまで進めました。今回は,Apps Script APIを外部から呼び出すために必要な情報と実際にAPIをどのように呼び出していくかを具体的に見ていきます。

クライアントアプリの作成

APIを呼び出す作業に入りたいところですが,まずはクライアントアプリを作成します。ここでいうクライアントアプリとは,アプリケーションやデバイスのことです。GASではHTMLを扱うことができ,PCを起動するスイッチなどを作ってもいいのですが,HTMLを書くのはちょっと面倒ですし,ファイルのアップロードができないため,ここではAppSheetを使うことにしました。AppSheetはノーコードで簡単にウェブアプリを作成できるツールです。

まず,Google Sheetsに戻り,拡張機能から「AppSheet」を選択して作成を始めます。

Google Sheet
AppSheet

すると,AppSheetの編集ページが表示されます。これで基本的には完成です。AppSheetはGoogle Sheetsをデータベースとして参照し,自動的にウェブアプリを作成してくれます。非常に簡単にアプリを作成できるので非常に便利です。

アプリができたらブラウザで開いてみましょう。このアプリは自分のアカウントでログインしないと見れないので,自分だけのアプリが完成したということです。また,スマホアプリを使ってログインすれば,スマホでも同じアプリを利用できます。もちろん,値を変更してSaveするとGoogle Sheetの内容も自動的に更新されます。

Desktop版

これでクライアントアプリの作成は完了です。

OAuth 2.0のフロー概要

Apps Script APIを呼び出す前にOAuth 2.0について確認しておきましょう。GoogleAPIでは認証と認可に OAuth 2.0 プロトコルを使用します。つまりAPIを呼び出すには,OAuth認証を用いて認可コードを取得し,その後アクセストークンを取得する必要があります。

まず,クライアントアプリケーションは認可サーバに対して認可コードの取得を要求します。認可サーバはユーザに対して「このクライアントアプリケーションに権限を与えてもよいですか?」と尋ねます。ユーザが「OK」を選択すると認可サーバは認可コードを発行し,クライアントアプリケーションに返します。その後,クライアントアプリケーションは取得した認可コードを用いてアクセストークンを取得します。この一連の流れがOAuth 2.0認証の基本的な流れです。

次にクライアントアプリケーションは,取得したアクセストークンを用いてリソースサーバのAPIにアクセスします。APIリクエストには,アクセストークンを含める必要があります。これにより,Apps Script APIへのアクセスが認証され,適切なデータを取得することができます。

全体の流れ

OAuth 2.0のフロー概要からAPIを利用するために必要なパラメータと,その全体的な流れを以下に整理します。必要なパラメータでは,それぞれどこで取得できるか()内に書いています。

必要なパラメータ

流れ

  1. 認可コードを取得

    • 必要パラメータ:クライアントID,スコープ,リダイレクトURI(承認済みURI
    • 認可サーバにリクエストを送り,ユーザーに承認を求めます。承認されると,認可コードが返されます。
  2. アクセストークンとリフレッシュトークンの取得

    • 必要パラメータ:クライアント ID,クライアントシークレット,リダイレクトURI,認可コード,スコープ(省略可)
    • 認可コードを使って,アクセストークンとリフレッシュトークンを取得します。これにより,APIリクエストを認証できるようになります。
  3. Apps Script API を呼び出す(GAS関数を実行)

    • 必要パラメータ:アクセストークン,実行可能API,実行する関数の指定(JSON形式)
    • 取得したアクセストークンを使用して,Apps Script APIにリクエストを送り,指定した関数を実行します。
  4. アクセストークンの更新

    • 必要パラメータ:クライアントID,クライアントシークレット,リダイレクトURI,リフレッシュトーク
    • アクセストークンの有効期限が切れたらリフレッシュトークンを使って新しいアクセストークンを取得します。
  5. 再度Apps Script API を呼び出す

    • 必要パラメータ:アクセストークン,実行可能API,実行する関数の指定(JSON形式)
    • 新しいアクセストークンを使って,再びApps Script APIにリクエストを送り,必要な関数を実行します。

developers.google.com

Apps Script APIの概要

Apps Script APIは,GASのプロジェクトや機能に対してプログラムから操作や情報の取得を行うための仕組みです。RESTful APIという設計方式を採用しており,HTTPリクエスト(GET、POSTなど)を送信することでGASの機能を呼び出したり,プロジェクトに関する情報を取得したりできます。

主なリソースとその役割

Apps Script APIは,複数のリソース(機能のグループ)で構成されています。

  • projects: スクリプトプロジェクトを操作します。
  • projects.deployments: プロジェクトを「デプロイ」(公開・配布)した際の状態を管理します。
  • projects.versions: プロジェクトの「バージョン」を管理します。
  • processes: スクリプトの実行プロセスを管理します。
  • scripts: これが最もよく使われる機能で、Apps Scriptの関数をリモートから実行するためのエンドポイントです。例えば,特定のGAS関数を外部から実行して,その結果を受け取ることができます。

今回GAS関数を外部から実行したいので scriptリソース を用います。

▼ 以下の記事でscriptsリソースについてまとめています。 zenn.dev

APIを叩いてみる

それでは実際にAPIを叩いてみましょう。ここで「APIを叩く」とは,APIを呼び出すことを意味します。いきなりESP32からAPIを呼び出すプログラムを書くのは難しいかもしれませんし,急にC++Pythonのコードが提示されても,その動作をイメージするのは難しいでしょう。

Apps Script APIはHTTP通信を使用するため,まずはインターネット環境を通じてAPIが正しく動作するかをテストする必要があります。これにより,実際のプログラムを書く前にAPIが正しく応答するか確認できるので,スムーズに開発を進めることができます。

OAuth 2.0 PlaygroundでAPIを叩く

HTTP通信のテストには,GUIグラフィカルユーザーインターフェース)やCUI(キャラクターユーザーインターフェース)の方法があります。ここでは,OAuth 2.0 Playgroundを活用することでGUI形式で簡単にAPIをテストすることができます。

OAuth 2.0 Playgroundは,GoogleのOAuth 2.0認証フローを使用してAPIを試すためのツールです。
以下のリンクからアクセスできます:

developers.google.com

このツールを使うことで,アクセストークンを取得し,実際にAPIリクエストを送信して応答を確認することができます。これにより,APIの動作確認やトラブルシューティングが容易になります。

まず,Google Cloud コンソールからクライアントIDとクライアントシークレットを取得します。これらは,認証情報を作成したときにメモをしていたと思います。もし忘れていた場合は,Google Cloud コンソールの「APIとサービス」> 「認証情報」のOAuth 2.0 クライアントIDを作成した場所にあります。

console.cloud.google.com

次に,OAuth 2.0 Playgroundの設定を開きます。「Use your own OAuth credentials」にチェックを入れると,クライアントIDとクライアントシークレットを入力する欄が表示されるので,ここにそれぞれペーストします。

OAuth 2.0 Playground

その後,「Step 1 Select & authorize APIs」のセクションで,APIのスコープを選択します。スコープとは、アプリケーションがアクセスするリソースや権限を定義するものです。「Apps Script API v1」を探し,タブを開くとスコープが表示されます。そこから「https://www.googleapis.com/auth/spreadsheets」を選択し,「Authorize APIs」ボタンをクリックします。

ここで認証画面が表示され,アカウントの認証を行うはずですが,エラーが発生することがあります。

これはOAuth 2.0 Playgroundがクライアントとして機能しているため,認可サーバにそのクライアントを正しく登録していないことが原因です。

認証情報を作成する

この問題を解決するには,Google Cloud Consoleの「認証情報」セクションで「承認済みのリダイレクト URI」を設定する必要があります。

承認済みのリダイレクト URI

ここに「https://developers.google.com/oauthplayground」をペーストしてください。URLの最後に「/」が含まれてしまうと認証が失敗するので,その点にも注意が必要です。ユーザーが認証されると指定したリダイレクトURIにリダイレクトされ,そのページに遷移することになります。

認可コードを取得

OAuth 2.0 Playgroundに戻り,再びスコープを選択して認証を進めます。

HTTP/1.1 302 Found
すると,Request / Responseのところに「HTTP/1.1 302 Found」というメッセージとLocationが表示されます。そして,その下にレスポンスメッセージとして「GET」が表示されたらOKです。レスポンスには認可コードが含まれているので,これを次のステップで使用します。

アクセストークンの取得

次に,「Step 2 Exchange authorization code for tokens」のセクションに進みます。ここでは,先ほど取得した認可コードを使ってアクセストークンに交換します。「Exchange authorization code for tokens」ボタンをクリックすると,アクセストークンとリフレッシュトークンが生成されます。

アクセストークンとリフレッシュトーク

  • リフレッシュトークン(Refresh token):アクセストークンの有効期限が切れた後,新しいアクセストークンを取得するために使用されるトークンです。
  • アクセストークン(Access token):Google APIにアクセスするためのトークンで,有効期限が約1時間(3600秒)です。

リフレッシュトークンは,アクセストークンが無効になった後でも再度新しいアクセストークンを取得するために必要です。これにより,ユーザは再認証せずにAPIを継続して利用できます。

Apps Script API の呼び出し

最後に「Step 3 Configure request to API」では,実際にAPIへHTTPリクエストを送信します。

Apps Script APIのscriptsリソース ここでは,外部からGAS関数を実行したいので,Apps Script APIのscriptsリソースを使用します。リクエストは以下のようになっています。
HTTP リクエストの形式:

  • HTTP メソッド: POST
  • リクエスURI: https://script.googleapis.com/v1/scripts/{scriptId}:run

リクエストボディの構成
リクエストボディには次のようなJSON形式でデータを記述します:

{
  "function": string,
  "parameters": [
    value
  ],
  "sessionState": string,
  "devMode": boolean
}

HTTP リクエス
まず,HTTP Methodを「POST」に設定します。そして,Request URIには「実行可能API」のURLを入力します。次に,「Enter request body」には,実行するGAS関数の内容をJSON形式で記述します。

たとえば,getCellValue関数を実行したい場合は以下のように記述します:

{'function': 'getCellValue'}

setCellValue関数を実行したい場合は,以下のように記述します:

{
  "function": "setCellValue",
  "parameters": ["TRUE"],
}

ここでは,パラメータとしてTRUEFALSEAPIで送信することで,GASが指定した関数を実行してGoogle Sheetsの値を更新してくれます。これにより,APIを呼び出してGoogle Sheetsのデータを操作することが可能になります。

最後に「Send request」ボタンをクリックします。レスポンスに「HTTP/1.1 200 OK」と表示されれば,リクエストは正常に送信されて処理が成功したことを示します。

APIへのリクエスト/レスポンス

そして,レスポンス内容には以下のようなJSONが表示されるはずです:

{
  "done": true, 
  "response": {
    "@type": "type.googleapis.com/google.apps.script.v1.ExecutionResponse", 
    "result": false
  }
}

この "result" の部分には,GASで定義した関数の return 値が返ってきます。たとえば,getCellValue 関数を実行した場合,Google Sheetsの特定セルの値がここに表示されるでしょう。

これで,OAuth 2.0 Playgroundを使ってAPIをHTTPで叩くテストが無事に完了しました。



CUIAPIを叩く

CUIAPIを叩く方法もあるので紹介しておきます。主にBashコマンドプロンプトでリクエストを送信する際に使用します。PowerShellも使えますが,書き方が少し複雑なのでここでは省略します。

Bashコマンドプロンプトでは,cURL(Client URL)というコマンドラインツールを使ってHTTPリクエストを送信します。cURLは非常に強力で,さまざまなプロトコルをサポートしているため,APIテストやスクリプトの一部としてよく利用されます。

新たな認証情報を作成する

まず,認可コードの取得ですが,これはcURLでは送信できないため,任意のブラウザでURLにアクセスしてリダイレクトされたページのURLから認可コードを取得します。

ただ,OAuth 2.0を使用するためにはクライアントを設定する必要があります。クライアントを設定する際に「承認済みのリダイレクト URI」を指定する必要があります。これは,ユーザーがGoogleの認証ページでログインを完了した後にリダイレクトされる先のURLです。このリダイレクトURIは事前にクライアント設定で登録しておく必要があります。

例えば,OAuth 2.0 Playgroundを使う場合は,リダイレクトURIhttps://developers.google.com/oauthplaygroundを設定しましたが,ローカル開発環境でテストを行う場合はhttp://localhost:3000のように設定することができます。

今回の場合,クライアントはAppSheetとして既に作成してあるので,そのURLをリダイレクトURIとして使います。まず,ブラウザで開いているAppSheetのURLをコピーしてください。
例えば,以下のようなURLになっていると思います: https://www.appsheet.com/start/***?platform=desktop#vss=*****-**-**-**

ちょっと長いURLですが,リダイレクトURIに使う部分は ?platform=desktop の直前までの https://www.appsheet.com/start/*** の部分だけです。?platform=desktop#vss=*****-**-**-** の部分はプラットフォーム情報なので,ここでは不要です。

そして先述の通り,Google Cloud Consoleの「認証情報」セクションで「承認済みのリダイレクト URI」を設定する必要があります。前回,認証クライアントを作成しましたが,また新しい認証クライアントIDを作成します。

新たな認証クライアント

適当に名前を決めて,承認済みのリダイレクト URIhttps://www.appsheet.com/start/*** をペーストして追加してください。

承認済みのリダイレクト URI

新たなクライアントIDとクライアントシークレットも忘れずにメモしておきましょう。

認証情報

認可コードを取得

設定が完了したら,そのリダイレクトURIを使って認可コードを取得します。

URLは次のようになります(わかりやすいように改行しています):

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
  • YOUR_CLIENT_ID クライアンID
  • YOUR_REDIRECT_URI リダイレクトURI(AppSheet)
    を変更してください。

このURLにアクセスすると,Googleアカウントの認証ページが表示され,承認するとAppSheetのページにリダイレクトされるはずです。 AppSheetのURLを見てみるとこのようになっています。

https://www.appsheet.com/start/***?code=***&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fspreadsheets&platform=desktop#***-24-09-10

AppSheetのアプリのURLにcode=***scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fspreadsheetsが追加されていることが分かります。 このURLの中で,code=*** という部分が認可コードになります。この部分をメモしておいてください。

ちなみに認可コードを取得する際のURLでは,access_type=offlineprompt=consentを含めることで,リフレッシュトークンも同時に取得できます。access_type=offlineはリフレッシュトークンを取得するための設定であり, prompt=consentはユーザーに再度同意を求めるための設定です。公式ドキュメントでは,access_type=offlineを指定することでリフレッシュトークンを取得できるとされていますが,実際にはユーザーに再度同意を求める必要があるため,少しややこしく感じるかもしれません。prompt=consentを併せて指定することで,ユーザーに明示的に再認証を促し,確実にリフレッシュトークンを取得することができます。

アクセストークンの取得

次に,cURLを使ってアクセストークンとリフレッシュトークンを取得します。cURLBashコマンドプロンプトで利用できるツールです。BashUnix系OSで広く使われており,コマンドプロンプトWindows OSに標準で搭載されています。

今回はWindowsで作業を行っていますが,Bashを中心に説明します。Windows環境でもGit BashなどをインストールすることでBashを使うことができます。もしBashをインストールしていない場合は,Google Cloud ConsoleのCloud Shellを使って作業を行うことも可能です。

Google Cloud Consoleにアクセスし,右上のCloud Shellをアクティブにしてください。

Cloud Shell
すると,ターミナルが画面下部に表示されます。$マークの後にコマンドを入力していきます。

以下のコマンドをBashに入力して実行してください:

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
  • YOUR_AUTHORIZATION_CODE 先ほど取得した認可コード
  • YOUR_CLIENT_ID クライアント ID
  • YOUR_CLIENT_SECRET クライアントシークレット
  • YOUR_REDIRECT_URI  リダイレクトURI
    を変更してください。

するとこのようなJSON形式でアクセストークンとリフレッシュトークンが返されます。

{
  "access_token": "ya29.***",
  "expires_in": 3599,
  "refresh_token": "***",
  "scope": "https://www.googleapis.com/auth/spreadsheets",
  "token_type": "Bearer"
}
Apps Script API の呼び出し

最後に,このトークンを使用してApps Script APIを呼び出します。

Apps Script APIのscriptsリソース ここでは,外部からGAS関数を実行したいので,Apps Script APIのscriptsリソースを使用します。リクエストは以下のようになっています。
HTTP リクエストの形式:

  • HTTP メソッド: POST
  • リクエスURI: https://script.googleapis.com/v1/scripts/{scriptId}:run

リクエストボディの構成
リクエストボディには次のようなJSON形式でデータを記述します:

{
  "function": string,
  "parameters": [
    value
  ],
  "sessionState": string,
  "devMode": boolean
}

getCellValue関数を実行したい場合

curl -X POST \
  -H "Authorization: Bearer access_token" \
  -H "Content-Type: application/json" \
  -d '{"function": "getCellValue"}' \
  https://script.googleapis.com/v1/scripts/***/run

このコマンドの説明は以下の通りです:

-X POST: POSTリクエストを送信します。

  • -H "Authorization: Bearer access_token": リクエストヘッダーにアクセストークンを指定します(access_token は実際のアクセストークンに置き換えてください)。
  • -H "Content-Type: application/json": リクエストボディのデータ形式JSONと指定します。
  • -d '...': リクエストボディのデータを指定します。ここでは実行したいGAS関数をJSON形式で指定しています。
  • https://script.googleapis.com/v1/scripts/***/run: リクエストを送信するAPIのエンドポイントです(実行可能API)。

このコマンドを実行すると やはりOAuth 2.0 Playgroundの時と同じようになります。

{
  "done": true,
  "response": {
    "@type": "type.googleapis.com/google.apps.script.v1.ExecutionResponse",
    "result": false
  }
}

resultがGoogle SheetのB2の値になっていることになります。 Google Sheetの値をTRUEに変えて同じコマンドで実行してみると

{
  "done": true,
  "response": {
    "@type": "type.googleapis.com/google.apps.script.v1.ExecutionResponse",
    "result": true
  }
}

resultがTRUEにになることが確認できました。

このようにして,CUI環境でも簡単にAPIを叩くことができます。レスポンスとしてJSONデータが返ってくるので,そこで結果を確認することが大切です。

setCellValue関数を実行したい場合

curl -X POST \
  -H "Authorization: Bearer access_token" \
  -H "Content-Type: application/json" \
  -d '{"function": "setCellValue","parameters": ["TRUE"]}' \
  https://script.googleapis.com/v1/scripts/***/run

setCellValue関数ではパラメータを受け取る仕様にしていたので,Google Sheetのセルの値をTRUEにしたいときパラメータ "parameters": ["TRUE"]とし,FALSEにしたいときは"parameters": ["FALSE"]にします。

アクセストークンの更新
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"

アクセストークンの更新についてのリクエストは,次のように実行します。YOUR_CLIENT_IDやYOUR_CLIENT_SECRET,YOUR_REFRESH_TOKENを置き換えてください。

まとめ

今回は,OAuth 2.0を使って認可コードを取得し,そのアクセストークンを使用してAPIを呼び出すテストの流れを見ていきました。次回は,ESP32を使って実際にAPIを叩き,返ってきたresultデータをもとにプログラムを動かしていく方法について説明します。

参考

GASとESP32で外出先からPCを起動する【第2部】

はじめに

本シリーズでは,Google Apps Script (GAS) と ESP32 を使って,外出先から自宅のPCをリモートで起動する方法を紹介しています。あくまで個人的な実践経験を共有することが目的のため正確性に欠ける部分があるかもしれませんが,参考にしていただければ幸いです。

前回の振り返り

前回の投稿では,ESP32を使って自宅のPCを外出先から起動する仕組みの概要を紹介しました。今回はその続きとして,実際にどのようにしてこのシステムを構築したか掘り下げていきます。今回の内容はより実践的で,具体的な手順に踏み込んでいくので,興味がある方はぜひ参考にしてみてください。

▼ 前回の投稿
yhotta240.hatenablog.com

まず,基本的な流れをおさらいします。Google SheetsでPCのステータスを管理し,ESP32が定期的にそのステータスをチェック。PCがオフの状態で,ステータスが変更された場合,ESP32がWake On LANのパケットを送信してPCを起動するという仕組みです。

以下は,Google Apps Scriptを用いてESP32デバイスからWake On LANを実行するシステムの全体的な構成図です。

構成図

構築手順
Google Cloud/AppSheetの連携は後にするとして,Google Sheetsを作成し,それをGASと連携させます。その後,GAS APIをデプロイし,ESP32からそのGAS APIを呼び出します。ESP32は,API呼び出しによって得た情報をもとに,自宅のルーター経由でデスクトップPCにWake On LAN信号を送信し,PCをリモートで起動します。

それでは,具体的な設定手順やプログラムについて詳しく見ていきましょう。

プロジェクトの作成

Google Sheets(スプレッドシート)

まずは,Chomeを開いてGoogleアカウントにログインします。Google Driveにアクセスして,新しいGoogle Sheetsファイルを作成します。今後も使う可能性を考慮して,ファイル名を「IoTプロジェクト」にしておきました。このシートでは,PCのステータスを管理するためのセルを一つ用意します。

今回は,ステータス列のB2セルにPCがオンかオフかを示す値を設定します。ここではTRUEとFALSEを使って表記しますが,値をいちいち入力するのは面倒なので,B2セルを選択した状態で「挿入」から「プルダウン」を選択します。すると,右側にデータの入力規則が表示されるので,オプション 1をFALSE(色は赤),オプション 2をTRUE(色は青)に変更して「完了」をクリック。これでステータスを簡単に切り替えられるようになりました。

Google Sheets プルダウン

Google Sheetsの設定は終わりです。 必要に応じてリビングの照明やテレビのオンオフ,エアコンの暖房,冷房などの項目を追加するのも良さそうですね。

Google Apps Script(GAS)

Google Apps Scriptは,Google Sheetsの拡張機能として簡単に連携できます。まず,Google Sheetsの「拡張機能」タブから「Apps Script」をクリックします。これで,Google Sheets専用のプロジェクトがGoogle Apps Scriptに自動的に作成されます。プロジェクト名は,わかりやすく「IoTプロジェクト」に変更しておきましょう。

Google Apps ScriptはJavaScriptをベースにしているので,JavaScriptの知識があればすぐに始められます。ただし,ファイルの拡張子は.jsではなく.gsになります。また,通常のJavaScriptとは少し異なり,Google独自の専用関数も用意されているので,それを使うことでGoogle Sheetsや他のGoogleサービスと簡単に連携できます。

まずは,Google SheetsとESP32がやりとりできるようにGASで関数を作成します。関数は,標準のJavaScriptを使って書くことができますが,Google Apps Scriptの特性を活かして作ると,さらに便利なツールになります。

その関数で,Google SheetsとESP32がやりとりできるAPIを作成します。

APIApplication Programming Interface)とは,アプリケーション間でデータや機能をやりとりするためのインターフェースです。APIといっても広義的な意味があり,今回の場合はWeb APIと呼ばれるものになります。Web APIは,HTTP通信を使ってデータを送受信することができ,通常はJSONJavaScript Object Notation)というデータフォーマットでデータが返ってきます。JSONは,データ交換に広く使われる軽量なフォーマットです。

以下のコードは,Google Sheetsのセルから値を取得し,その値をJSON形式で返すように設定したものです。このコードをGoogle Apps Scriptのコード.gsに貼り付けます。

function doGet(e) {
  // スプレッドシートの指定シートを取得
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Sheet1');
  
  // セルB2の値を取得
  const cellValue = sheet.getRange('B2').getValue();
  
  // JSON形式でレスポンスを作成
  const jsonResponse = {
    "cell": "B2",
    "value": cellValue
  };
  
  // JSONレスポンスを返す
  return ContentService.createTextOutput(JSON.stringify(jsonResponse))
                       .setMimeType(ContentService.MimeType.JSON);
}

このdoGet関数は,HTTP GETリクエストが来たときに呼び出されます。Google Sheetsの「シート 1」シートのB2セルの値を取得し,それをJSON形式で返します。これにより,ESP32からこのAPIを呼び出すことで,Google Sheetsのセルの値を簡単に取得することができます。

次に,このAPIをESP32から呼び出す方法について説明します。APIのURLをESP32でリクエストし,レスポンスを受け取る準備をしていきましょう。

Web APIの公開リスク

といいたいところですが,このままではセキュリティ的な問題が生じます。現在のdoGet関数はウェブアプリとして公開(デプロイ)し,HTTP GETリクエストが来たときに呼び出されます。ESP32(外部)からのアクセスになるため,「アクセスできるユーザー」を全員に設定しなければなりません。この状態では,ウェブサイト(HTMLなど)やデータをSheetに書き込むだけ(フォームとして)の機能であればデプロイしても問題ないですが,ウェブサイトをJSON形式で公開するとなると,読み取られると困るデータも含まれるかもしれません。しかも今回はWake On LANなのでもし悪意のあるユーザーに大量のリクエストやパラメータを送信されてしまうと,勝手にPCが何度も起動されるという事態になりかねません。このため,今回はこの方法を採用しません。

セキュリティを考慮したWeb API設計

ウェブアプリとして公開したAPIは,インターネット上で誰でもアクセスできる可能性があるため,セキュリティ面でのリスクがあります。特に,誰でもAPIにアクセスできる(ウェブアプリとしてデプロイした)状態では,意図しない操作やデータの漏洩が発生する恐れがあります。

デプロイやAPIの設計が簡単すぎるためか,多くの記事や技術ブログではこの方法が使われていますが,セキュリティ的な問題を完全に無視していると感じます。URLやパラメータを知っていれば誰でもGoogle Sheetsの値を読み取ったり,変更できたりしてしまうので,ここでは認証機能を備えた実行可能APIをデプロイする方法にしました。

認証機能を備えた実行可能API

さて,セキュリティを考慮して認証機能を備えた実行可能APIを作成していきます。先ほどのコードを削除し,以下のコードをコード.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 + "' に更新しました。" };
}

このコードは,getCellValuesetCellValueという2つの関数を定義しています。これらの関数は,Google Sheetsの特定のセルの値を取得したり更新したりするためのものです。貼り付けたらプロジェクトを保存します。

ウェブアプリとして公開する場合は,通常doGet関数やdoPost関数を用いて処理を行いますが,実行可能APIを使用すると関数を直接呼び出すことができます。これにより,よりセキュアにデータの取得や更新が可能です。

  • getCellValue関数:指定されたシート(Sheet1)のB2セルの値を取得し,その値を返します。ESP32から呼び出すことで,PCのステータスを確認できます。

  • setCellValue関数:指定されたシートのB2セルに新しい値を設定します。この関数を使って,ESP32からPCのステータスを更新できます。更新後,成功メッセージが返されます。

このようにして,Google SheetsとESP32の間で認証を行いながら,データのやり取りを安全に行えるようになります。

もしうまくできない場合は,以下のGoogle SheetsのURLにアクセスしてコピーを作成してください。
▼ サンプルファイル https://docs.google.com/spreadsheets/d/1t3yB8KrYqWjH4dTXSewf_qH3gW9w8pDPr-FdERIJGbY/edit?usp=sharingdocs.google.com

実行テスト

getCellValueを選択して実行します。

GAS 実行テスト
はじめて実行する際,承認を求められますので自分のGoogleアカウントへのアクセスを許可します。
承認

するとプログラムが実行され,実行ログに実行完了と出たらOKです。 次に,この実行可能APIをESP32からどのように呼び出すかを見ていきましょう。

実行可能APIのデプロイ

続いて,実行可能APIをデプロイしていきます。右上のデプロイボタンをクリックし,新しいデプロイを選択します。ウェブアプリではなく実行可能APIを選択します。

実行可能 API を デプロイするには、この Apps Script プロジェクトで使用するプロジェクトをユーザーが管理する Google Cloud Platform(GCP)プロジェクトに切り替える必要があります。

Google Cloud プロジェクト  |  Apps Script  |  Google for Developers

事前にプロジェクトを作成していない場合はGoogle Cloud Platform(GCP)でプロジェクトを作成します。

プロジェクトの種類を変更をクリックするとGASのプロジェクトの設定に飛びます。下の方にスクロールすると 「Google Cloud Platform(GCP)プロジェクト」という項目があるのでプロジェクトの変更をクリック
手順が出てくるのでこの通りにGCPでプロジェクトを作成します。

GASとGCPの連携

Google Cloud Platform
  1. Google Cloud Platformプロジェクトの設定
    まず,Google Cloud Platform(GCP)に移動してプロジェクトを作成する必要があります。既存のGCPプロジェクトを使用することもできますが,新しいプロジェクトを作成する場合は次の手順を参考にしてください。

  2. Apps Script APIの有効化
    スコープを有効にするためにApps Script APIを有効にします。

    • GCPコンソールの左上にあるナビゲーションメニューを開き,「APIとサービス」 > 「ライブラリ」を選択します。
    • 検索バーに「Apps Script API」と入力し,表示された結果からAPIを選択します。
      API ライブラリ
    • 「有効にする」をクリックして,APIを有効にします。
      App Script API
  3. 認証情報(OAuth 2.0 クライアント ID)を作成
    GCPのコンソール上で,認証情報を作成します。認証情報はAPIキーではなく,OAuth 2.0 クライアント IDを使用します。

    • GCPコンソールの左上にあるナビゲーションメニューを開き,「APIとサービス」 > 「認証情報」を選択します。
    • 検索バーに「認証情報の作成」の中から「OAuth 2.0 クライアント ID」選択します。
    • アプリケーションの種類をウェブアプリケーションにし,のまま「作成」をクリックします。
      OAuth クライアントの作成
    • 作成後に表示されるクライアントIDとクライアントシークレットを安全な場所にメモしておきます。これらは,後でAPIリクエストを行う際に必要になります。
      クライアントIDとクライアントシークレット
  4. OAuth 2.0の設定

    1. OAuth同意画面

      • APIとサービス」 > 「OAuth同意画面」に移動し,ユーザータイプを「外 部」に設定して作成します。
      • アプリ名とユーザサポートメール,デベロッパーの連絡先情報を入力して次へ進みます。
    2. スコープ

      • 「スコープの追加または削除」をクリックし,スコープの追加画面で「手動でスコープを追加」からhttps://www.googleapis.com/auth/spreadsheets を追加します。これにより,Google Sheetsへの読み取りと書き込み権限が付与されます。
        スコープの手動追加
    3. テストユーザ

      • テストユーザーに自分のメールアドレスを追加しておきましょう。
  5. Apps ScriptプロジェクトとGCPプロジェクトのリンク
    最後に,作成したGCPプロジェクトをGoogle Apps Scriptプロジェクトにリンクします。Cloudコンソールのダッシュボードに戻り,プロジェクト情報の欄にあるプロジェクト番号をコピーしておきます。

    プロジェクト番号
    Google Apps Scriptに戻り,GCPのプロジェクト番号にコピーした番号をペーストし,プロジェクトを設定する
    GCP プロジェクト番号を設定

デプロイの完了

ここまで来てようやくデプロイできます。

  • 再びGoogle Apps Scriptのエディタ画面に戻り,デプロイボタンをクリックして「新しいデプロイ」を選択します。
  • 歯車のアイコンをクリックし,実行可能APIを選択します。
  • 説明を入力し,アクセスできるユーザーを「自分のみ」に設定します。ここがウェブアプリとして公開する際の違いです。
  • デプロイが完了すると,デプロイIDと実行可能APIのURLが表示されますので,これをメモしておきます。
    デプロイIDと実行可能API

これで,認証機能を備えた実行可能APIの作成が完了しました。

デプロイが完了するとデプロイIDと実行可能APIのURLが出てくるのでメモします。メモし忘れても「デプロイを管理」から確認できます。

GASの関数の実行には、scripts.runのAPIを使用します。
URLは、「https://script.googleapis.com/v1/scripts/{scriptId}:run」(※)となります。

※scriptIdには、GASエディターの「設定」で確認できるスクリプトIDを入れる旨がドキュメントには記載されていますが、デプロイ時に表示されるURLに含まれるIDを使用しないと動かないケースもありますので、ご注意ください

引用元:GASで書いた関数を外部から叩いてみる
URL:https://recruit.gmo.jp/engineer/jisedai/blog/run-gasscript-via-api/

API呼び出し

次回は,このAPIを呼び出す方法について説明します。Web APIはHTTP通信を使用してデータを送受信するため,ESP32からもHTTPリクエストを送信することでGoogle Sheetsとデータのやりとりが可能になります。

次回の投稿で,これらの手順を説明していきます。

▼ 次の投稿 yhotta240.hatenablog.com

Google Apps ScriptとESP32で外出先からPCを起動する(Wake On LAN)

概要

Google Sheets(スプレッドシート),Google Apps Script(GAS),ESP32を使って外出先から自宅PCを遠隔起動(Wake On LAN,Wake On Wan)する仕組みを作ってみました。さらに,AppSheetを組み合わせて,スマホから簡単に起動できるようにしたので,その過程を紹介します。

はじめに

最近,VPNを使って外出先からノートPCで自宅PCにアクセスし,リモートデスクトップを利用するようになりました。これでデスクワークやPCゲームもできるようになったのですが,リモートデスクトップを使うためには,PCを常に起動しておく必要があり,消費電力がかかるだけでなくPCの寿命も縮めてしまうのではないかと気になっていました。 そこで,「使いたいときにPCを起動できたら便利じゃないかな?」と思い,試行錯誤しながら外出先からPC起動する仕組みを作ってみました。

本シリーズでは,Google Apps Script (GAS) と ESP32 を使って,外出先から自宅のPCをリモートで起動する方法を紹介しています。あくまで個人的な実践経験を共有することが目的のため正確性に欠ける部分があるかもしれませんが,参考にしていただければ幸いです。

Wake On Lanについて

PCを遠隔で起動することを「Wake On LAN」と呼ぶらしいのですが,これは通常自宅内のLAN環境でPCを起動することを指します。ですが,今回は外出先から自宅PCを起動したかったため,Wake On LANだけでなく,外部からの起動も視野に入れています。

色々調べたところ,スマホから起動できる方法が見つかりましたが,外部からアクセスするにはルータのポートを開放する必要があり,セキュリティ面での不安や私のマンションのルータの設定ではポート開放ができないという問題がありました。

そこで,スマホから起動できるのであれば,省電力のコンピュータからコマンドを送信してPCを起動できるのでは?と考え,マイコンからArduino,そしてESP32というArduino互換のマイコンにたどり着きました。

ESP32とは

ESP32は,Wi-FiBluetooth通信が可能なマイコンです。Arduino互換で低価格ながらも高性能で,多くのプロジェクトで使われています。特に,IoTデバイスの開発に適しており,センサやデバイスをインターネットに接続してデータの送受信を行うことができます。

ESP32は低消費電力で動作するため,常に待機状態にしておいて,必要なときにだけPCを起動するという用途に非常に適していると考えました。他のユーザーもESP32を使ってWake On LANを実装している例が多く,私もこれを使って遠隔起動を試してみることにしました。


ESP32にもいろいろな種類があるので,どれを買えばいいのか少し迷いました。普段はマイコンや電子工作をしないので,どこで買えばいいのかも悩んだのですが,ESP32は秋月電子通商で購入できるとのこと。

さっそく「ESP32-DevKitC-32E ESP32-WROOM-32E開発ボード 4MB」を注文しました。価格は1,600円,送料は500円でした。実際に手に取ってみると,その小ささに驚きました。

akizukidenshi.com

PC環境と設定

まずは,PCにWake On LANパケットを送信するために,PCの設定を変更します。

PCの設定

実行環境

必要な設定

  1. IPアドレスの固定と確認
  2. ネットワークアダプタの設定
  3. BIOS/ UEFI の設定
  4. シャットダウン/ ハイブリットシャットダウン
  5. マジックパケットでの起動を有効にする

必要な情報

【例】
IPv4アドレス  : 192.168.0.120
物理アドレス(MAC) : A1-B2-C3-D4-E5-F6

以下のサイトを参考にして設定を変更してください。

eizone.info

スマホからPC起動

設定が完了したので,スマホから自宅PCの起動を試してみました。手順は以下の通りです。 以下のURLからアプリをダウンロードします

App Store (iOS)

RemoteBoot WOL

RemoteBoot WOL

  • Takashi Tsuchiya
  • ユーティリティ
  • 無料
apps.apple.com

Google Play (Android)

play.google.com

  1. アプリを開き,右上の「+」ボタンを押して新しい接続先を作成する
    • Name: 自分がわかりやすい適当な名前を入力
    • IP or HostName: 192.168.0.255(他のIPアドレスと重複しないもの)
    • Macアドレス: 先ほど取得したものを入力し,形式をA1-B2-C3-D4-E5-F6からA1:B2:C3:D4:E5 に変更
    • ポート番号: そのまま「9」に設定
    • IP or HostName for Ping: 先ほど取得した自宅PCのIPアドレスを入力
  2. 設定を終えていざ起動!!右下の「Boot」ボタンをタップして起動

無事に一発で起動できました!もし起動できなかった場合は,自宅PCのMacアドレスが間違っているか,または「IP or HostName」に入力したIPアドレスが既に使われている可能性がありますので,再度確認してみてください。

Arduino IDE インストール

スマホからのPC起動が確認できたので,ESP32でのPC起動を試してみます。まずは,家のルータに記載されているSSID(2.4GHz)とPASSWARDをメモします。

ESP32はArduinoというマイコンの互換機なので,Arduino IDEというソフトウェアを使ってプログラムを書き込みます。詳しいインストールとセットアップ方法については,以下のリンクを参考にしてください。

tool-lab.com

image.png

www.arduino.cc
この時のArduino IDEのバージョンは2.3.2でした。

インストールが完了したら,Arduino IDEを起動します。 ESP32を使えるようにするには 「追加のボードマネージャのURL」と「EPS32プラットフォーム」のインストールが必要になります。

  1. 追加のボードマネージャのURL ファイルから「基本設定」を開きます。
    安定版リリースリンク(推奨):
    https://espressif.github.io/arduino-esp32/package_esp32_index.json
    開発リリースリンク:
    https://espressif.github.io/arduino-esp32/package_esp32_dev_index.json
    のどちらかのURLを「追加のボードマネージャのURL:」にペーストしOKをクリック image.png

  2. EPS32プラットフォーム 左端にあるボードマネージャタブから「ESP32」と入力して検索し,esp32 by Espressifをインストールします。 esp32 by Espressif(バージョン 3.0.4) インストールには少し時間がかかるかもしれません。 image.png

▼ 公式ドキュメント

docs.espressif.com

次に,USB 2.0 A-Micro Bケーブルを用意します。ESP32にmicro-Bを,PC側にType-Aを接続します。このとき,接続した瞬間にPCから音が鳴り,デバイスマネージャの「ほかのデバイス」に「CP2102N USB to UART Bridge Controller」が表示されると思います。 image.png

ここで気づいたことがあるのですが,USBケーブルには充電専用と通信用の2種類があります。最初に家にあるケーブルで接続したところ,充電専用ケーブルだったためPCがESP32を認識しませんでした。家の中を探して通信用ケーブルを見つけたので大丈夫でしたが,ここは見落としがちなポイントです。もし家になければ,家電量販店で購入するか,秋月電子通商で170円程度で購入できますので,ESP32と一緒に購入しておくと良いでしょう。

▼ 通信用USBケーブル購入リンク(秋月電子通商

akizukidenshi.com

バイスマネージャを開き,「ほかのデバイス」にある「CP2102N USB to UART Bridge Controller」を探します。このような表記になってい場合,ドライバがインストールされていない可能性があります。 以下のリンクから,ドライバをダウンロードします。

Silicon Labs CP210x USB to UART Bridge Driver

https://jp.silabs.com/developers/usb-to-uart-bridge-vcp-drivers?tab=downloadsjp.silabs.com

  1. ダウンロードタブからWindows OSであれば「CP210x Universal Windows Driver」をダウンロード
  2. ダウンロードしたファイルを適当な場所で解凍
  3. バイスマネージャに戻り,「CP2102N USB to UART Bridge Controller」を右クリックして「コンピュータ上のドライバを参照」を選択し,解凍したフォルダのパスを指定
  4. 「サブフォルダも検索する」にチェックを入れてインストール スクリーンショット 2024-09-01 083430.png

設定が完了すると,「ポート(COM と LPT)」に「Silicon Labs CP210x USB to UART Bridge(COM5)」が表示されます。最後の「COM5」はPCによって異なるため,メモしておきましょう。今回の場合は「COM5」になります。

image.png

ボードとポートの選択

Arduino IDE に戻り,上部のツールメニューを開きます。

ボード > esp32 > ESP32 Dev Module を選択します。 image.png

次に再びツールメニューを開き,ポート > COM5(PCによって異なる)を選択します。 image.png

Arduino IDEにESP32 Dev Moduleが表示されていればOKです。

PC遠隔起動テスト

Wake on LAN テストプログラムを作成

ESP32から自宅PCをWake On LANで起動するテストプログラムを作成します。まず,Arduino IDEの右側にあるライブラリマネージャーから必要なライブラリをインストールします。
ライブラリ:WakeOnLan by a7md0(バージョン 1.1.7)

インストールが完了したら,以下のコードを新規スケッチに貼り付けます。 wol-esp32-test.ino

#include <WiFi.h>
#include <WiFiUdp.h>
#include <WakeOnLan.h> 

// Wi-Fi設定
WiFiUDP UDP;
WakeOnLan WOL(UDP);
const char* ssid = "SSID";  // ここにWi-FiのSSIDを入力
const char* password = "PASSWORD";  // ここにWi-Fiのパスワードを入力
// MACアドレス設定
const char* pcMacAddress = "AA:BB:CC:DD:EE:FF";  // ここに自宅PCのMACアドレスを入力

void setup() {
  Serial.begin(115200);
  // Wi-Fiに接続
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("WiFiに接続中...");
  }
  Serial.println("WiFiに接続完了");
  Serial.println(WiFi.localIP());

  // マジックパケット送信
  WOL.sendMagicPacket(pcMacAddress);  // MACアドレスを使用してマジックパケットを送信
  Serial.println("Wake on LANパケット送信");
}

void loop() {
  // 何も実行しません
}

プログラムの設定

上記のプログラムを使用する前に,以下の箇所を自分の環境に合わせて書き換えましょう。 WifiSSIDとPASSWORD,自宅PCの物理アドレスMACアドレス)が必要です。

// Wi-Fi設定
const char ssid = "SSID";  // Wi-FiのSSIDを入力
const char password = "PASSWORD";  // Wi-Fiのパスワードを入力
// MACアドレス設定
const char* pcMacAddress = "AA:BB:CC:DD:EE:FF";  // 自宅PCのMACアドレスを入力

簡単にプログラムを説明すると

  1. 最初に必要なライブラリを読み込む
  2. ssidpasswordWi-FiSSIDとPASSWORDを保存
  3. pcMacAddressにPCのMACアドレスを保存
  4. setup関数: プログラムの開始時に一度だけ実行され,Wi-Fiに接続が完了したら,PCを起動するためのマジックパケットを送信
  5. loop関数: テストのためここでは何も実行しない

テストプログラム書き込み・実行

いよいよESP32にプログラムを書き込んでみます。まず,ESP32をPCに接続した状態でArduino IDEの右上にあるシリアルモニタをクリックしてください。すると,下部にシリアルモニタが表示されます。

ESP32のデフォルト設定では,シリアルユニットの通信速度が115200baudに設定されていますので,シリアルモニタの通信速度も115200baudに合わせておきましょう。

準備ができたら,書き込みボタンをクリック。これでコンパイルが始まり,しばらくするとESP32ボードにプログラムが書き込まれます。もしプログラムにエラーがあると,出力(Output)にエラーが表示されます。

書き込みが完了すると,プログラムが自動的に実行されます。シリアルモニタに

WiFiに接続中...
WiFiに接続完了
Wake on LANパケット送信

と表示されたら成功です。 WiFiに接続完了が出ない場合は,SSIDやパスワードの打ち間違えを確認したり,ルータの再起動や暗号化方式などを変えてみたりしてください。ESP32はAES,WPA,WPA2-PSK,WPSに対応しています。

自宅PCがすでに起動している場合は,もちろん電源はついたままなので,プログラムが実行されても何も起こりません。PCを一度シャットダウンしてから,ESP32を使ってWake On LANを試してみました。

ESP32は5Vの電圧でも動作するそうなので,ESP32につないだUSBケーブルをスマホの充電に使っていたUSB-ACアダプタにつなげてコンセントに差し込みました(急速充電用のUSB-ACアダプタはやめた方がいいかもしれません)。

スイッチなどは特にないため,コンセントに差した後,数秒経つとPCの電源が起動します。

これでESP32からPCを起動することができました!ここまでがESP32を使ってPCを起動するWake On LANを実際に試してみた結果です。

問題点

外出先からWake On LANする

同じLAN内にいるときはWake On LANでPCを起動できましたが,外出先からどうやってPCを起動すれば良いのでしょうか?

1つ目の方法として,先述の通りルータのポートを開放する方法があります。ですが,セキュリティ面での不安がありますし,私が住んでいるマンションのルータではポート開放ができないという問題も…。

2つ目の方法として,Wake On LAN専用ルータやVPN機能があるルータを購入する方法もあります。ただし,わざわざWake On LANのためだけにルータを買い替えるのもなぁ…と,ちょっと気が引けますよね。

3つ目の方法として,ChatGPTに聞いてみたところ無料のVPNサーバーソフトウェア使用すると提案されましたが,自宅PCがオフの状態ではVPNサーバーを設置しても意味がないという的外れな回答が返ってきました笑 おそらく余っているPCがある前提での提案だったのでしょうが,実際には私の自宅にあるPCは1台しかありません。

4つ目の方法として,省電力の装置(ArduinoやESP32など)を自宅に置き,HTTP通信を利用してPCを起動する方法を考えました。これなら低コストかつセキュアにPC起動できそうだなと判断しました。 なので,タイトルにもある通り「Google Apps Script」と「ESP32」を組み合わせて自宅PCを外出先から起動する方法を試してみることにしました。

解決案

どうやって実現したかというと,Google Apps Script (GAS)を活用して外部からESP32にアクセスできるようにしました。具体的には,Google Sheetsを使ってPCの起動ステータスを管理し,Google Apps ScriptのAPIを通してESP32がそのステータスを定期的にチェックし,PCがオフになっていてGoogle SheetsのステータスがFALSEからTRUEに変化していればWake On LANのパケットを送信するという仕組みです。 ちょっとしちめんどくさいですが,要はGoogle SheetsがPCのスイッチみたいな役割を果たしてくれるわけです。シートのB2セルがTRUEになった瞬間,ESP32が「PCを起動しろってことだな!」と判断して,PCの電源を入れてくれます。

やり方をまとめると,以下の通りです:

  1. Google Sheetsでステータス管理 PCのステータスをTRUEFALSEで管理するシンプルなシートを作成
  2. Google Apps Scriptで関数を作成 シートのステータスを取得・更新するための関数をGoogle Apps Scriptで作成
  3. ESP32で定期的にAPIをチェック ESP32が一定時間ごとにGoogle Apps Script APIにアクセスし,PCがオフならステータスをチェック
  4. Wake On LANパケットの送信 ステータスがFALSEからTRUEに変わったときのみESP32がWake On LANマジックパケットを送信
  5. ステータスの自動更新 PCがオフになったとき,ステータスを自動的にTRUEからFALSEに変更。シート上のステータスが正しく保たれるので,無駄にPCを起動しようとすることを防ぐ

この方法ならポートを開放する必要もないし,専用のルータを買う必要もありません。ただ,セキュリティ的にはまだ足りない部分もあり,プログラムも少し長くなってしまったので,もっとシンプルにできないか考える必要もあります。

とにかく,ここまで準備が整ったのですが,Google Apps Scriptのプログラム作成とAPIの構築,そのAPIを叩くESP32用のプログラムを書き込む作業がまだ残っています。もし興味がある方が多いようなら,続きを書こうと思っています。

Part 2(続き)を投稿しました。

yhotta240.hatenablog.com