概要
Espressif社が開発したESP-NOWは、IEEE802.11のMACフレームの一種であるアクションフレームを使ってEspressif社のWiFiデバイス間でデータを送受信するための独自技術で、ESP-WROOM-02(ESP8266EX)やESP-WROOM-32(ESP32)間で使うことができる。ふつうのWiFiのステーションーアクセスポイント間の接続確立やIPアドレスの取得や設定もないから、余計な時間をかけずにデータの送信ができる。
一回につき250バイト以内のデータしか送信できないが、温度センサーの測定値を送るには十分だろうということで、屋外や室内で使っているWROOM-02 + 温度センサーの電池のモチをよくするため、ESP-NOWを使ってみることにした。使い方も簡単で有効性も高い印象。
今回の実装について
4台のESP-WROOM-02(D) + 温度センサーのボードを送信側、1台のESP-WROOM-32を受信側とした。
1 2 3 4 5 6 7 8 9 |
esp-now transmission Ctrl1(esp8266) --------+ | ESP-32 Ctrl2(esp8266) --------+---> (Slave) <---> WiFi Router <---> internet | (<--- (tcp connection) --> httpd) Ctrl3(esp8266) --------+ Ctrl4(esp8266) --------+ ... |
ESP-NOWの世界では、おもに送信を行う側をコントローラ、受信側をスレーブと称する。
今回は以下のように作った。
- コントローラ側は測定間隔ごとにディープスリープから復帰して測定およびデータ送信を行う。スリープ復帰から送信完了して再度スリープするまでの所用時間は約20~30msec程度。通常のWiFi接続時の1/100以下である。
- 受信側はデータ受信都度ごとにインターネット接続用のWiFiルーターに普通に接続してWebサーバーにGETリクエストでデータを送る。リクエストを受けたWebサーバーは従来とおりにMySQLにデータを格納する。こちらは受信完了からhttpリクエストまでに3.5秒間ほどかかってしまう。
資料を読んだり実際に使ってみてポイントと感じたことは以下のとおり。
- コントローラ側APIでデータ送信の成功/失敗を検出できる。
送信したMACフレームに対するACKが得られなければ失敗になるようだ。送信成功ステータスが得られたときは、スレーブに届いていると考えて良さそう。 - コントローラ側APIでは送信失敗の理由を検出できない。
他のコントローラと送信タイミングが重複したときや、スレーブ側が待ち受けていない場合などに失敗するが、その理由はAPIからはわからない。失敗時にはスケッチで再送することになるが、他のコントローラと送信タイミングを変えなければ失敗を繰り返すだろう。 - 送信失敗を検出しても、スレーブ側で正しく受信されていることがある。
失敗を検出した場合コントローラ側は再送するしかない。しかしながら再送前のデータが正しく受信されていることがある。再送したデータも正しく受信されれば同タイミングでの重複データとなる。重複が生じたことを検出できるようにしておく。 - スレーブ側でデータの有効性をチェックする。
受信成功以外は検出できないので、一応データ長、および、チェックサムやCRCによってデータが有効であることを確認することにする。 - API(WiFiクラス)に与えるWiFiチャネルはともに0を指定する必要がある(なんでか分からない)。試してみたが、0以外は通らなかった。
今回作成したコントローラ側およびスレーブ側のスケッチなどはこちらのページに掲載した。
ESP-NOW関係の資料は以下のとおり。
- ESP-NOW User Guide
公式ガイド。 - Docs » API Reference » Wi-Fi API » ESP-NOW
ESP32用のAPIリファレンス。MACフレームの構成図なども含まれている。 - ESP8266 Non-OS SDK API Reference (version2.2)
ESP8266用のAPIリファレンス - 実装例として、「ESP8266 の低消費電力の限界をさぐる (ESP-NOWを使ってみる)」を参考にさせていただいた。
なお現段階(2018年5月)では、ESP8266用とESP32用とでインクルードすべきヘッダファイル名が異なっているし、API関数も若干異なっている。
ESP8266用は #include <espnow.h> だが、ESP32用では#include <esp_now.h> とする必要があるし、8266にあって32には 存在しないAPI関数もあった。
スレーブ側の構成概要
スレーブ側は、WROOM-02が余ってなかったのでESP32 DevKit-C(秋月電子のESP-WROOM-32開発ボード)をふつうのブレッドボードに載せ、WiFi接続時に点灯するインジケーター用としてLEDを1つ追加して使った。GPIO13から2KΩの抵抗を介してLEDのアノードに接続しカソード側はGNDに接続。
電源は、ボード上のUSBマイクロ-Bにスマホ充電用のACアダプタを接続した。コントローラとスレーブをあわせた全体の消費電力は増えることになるかもしれないが、センサー側の電池が長持ちすればそれでいい。
スレーブ側スケッチ
スレーブ側は、ESP-NOWでデータを待ち受け続ける。そして受信が完了するたびごとにインターネット接続用のWiFiルーターに接続し、データをインターネット上のWEBサーバーに投げる。全体して見ると、今までWROOM-02 + 温度センサーボードが単独で実施していた処理の一部をESP32に代行してもらうわけである。
以下、順不同になるがデータ構造と関数の動作概略を記す。スケッチ自体は先に載せたページを参照のこと。
送受信データ構造
コントローラとスレーブは以下の構造体データを使ってデータをやり取りする。パックしているので大きさは24バイト。ヘッダファイルに出してライブラリとしてインクールドしようかと思ったが、とりあえず両方のスケッチにベタッと書いた。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#pragma pack(push, 1) typedef struct { uint16_t seq_no; // 送信シーケンス番号 0~ uint16_t elapsed; // スリープ復帰~スリープまでの経過時間 (msec) uint16_t extra; // TBD uint8_t retry; // 同一フレーム送信リトライ数 0~ float T; // 温度 float H; // 相対湿度 float P; // 気圧 float V; // 電源電圧 uint8_t checksum; // チェックサム } sensor_data_t; #pragma pack(pop) |
コントローラ側ではこの構造体データをRTCメモリにも保存し、ディープスリープをまたいでシーケンス番号、前回送信時の所用時間、リトライ回数の累積に使っている。
スレーブ側は、送られてきたリトライ回数や経過時間を温度などの測定値とともにWebサーバーに送信する。また、送信シーケンス番号を直前に受信したものと比較し同じ送信機会のデータの重複もチェックする。
elapsedフィールドには、前回(シーケンス番号 – 1) における、スリープ復帰~送信~スリープ開始直前までに要した時間がmsec単位で格納されている。したがって初回送信時(リスタート直後)には0が来る。送信リトライが発生していた場合、リトライ時に要した時間もelapsedに合算されるので、あるシーケンス番号のデータを送るのに要したWiFi稼働時間が分かる。
void start_espnow();
ESP-NOWのスレーブ側を開始するための処理を行う。まずWiFi.mode(WIFI_AP);とWiFi.softAP(ssid, …); によってソフトAPモードを開始してから esp_now_init(); によってESP-NOWの使用を宣言する。今回のスケッチではWiFi.softAP(); でssidやパスワードを指定しているが、コントローラ側ではスレーブのMACアドレスを指定するだけでssidやパスワードは使わない。あくまでご近所の方からのいたずら接続を防ぐためで、スマホから見えないようSSIDもHiddenとした。
初期化が終わると、esp_now_register_recv_cb(onReceive); によってデータ受信完了時のコールバック関数を指定し、後はデータ受信を待つだけになる。
ESP32用のAPIヘッダファイルには、コントローラかスレーブかを宣言するための、esp_now_set_self_role(); というAPIが存在しない。ESP8266用SDKとは概念が変わったのかもしれないし、ESP32側が未整備なだけかもしれない。少なくとも、今回のようにsoftAPモードを指定している場合は、スレーブとして動作するようだ。
また、esp_now_add_peer(); によって接続を受け入れるコントローラ側を事前に登録しておく必要もないようだ。スレーブのMACアドレス宛のESP_NOW送信はすべて受信するように見える。この点は、コントローラとして使うモジュールを増やしたり変えたりするたびにスレーブ側スケッチを直さなくて済むから楽である。逆に、このAPIを使ってMACアドレスを登録しておくことで受信対象を限定できるのかもしれないが、今のところ試していない。
void setup();
各種初期化処理してstart_espnow(); を呼びloop(); に入る。
void onReceive(const uint8_t *mac, const uint8_t *data, int data_len);
esp_now_register_recv_cb() の引数となるデータ受信通知用のコールバック関数で、ドキュメントにWiFiタスク内から呼び出すのでさっさと仕事を終わらせよと書いてある。
まず、送信側のMACアドレスを変数 sender_mac にセットし、コントローラ側と取り決め済みのデータ長( sensor_data_t 構造体のバイト数)が一致していれば受信データを変数rx_data にコピーする。そして、データ内のチェックサムが算出値と一致していればグローバル変数の data_received = true; としてリターンする。
受信したデータの処理中に次を受信してしまわないよう、esp_now_unregister_recv_cb(); を呼びデータの受信通知を解除する。
今回の実装では、コントローラースレーブ間のハンドシェイクがないので、もしも無効なデータ(データ長違いやチェックサム違い)の受信が成功した場合の再送要求を行う手段がない。おそらくコントローラ側では送信成功となっているから、そのデータは再送されず失われることになる。
void loop();
onReceive(); が立てる data_recieved を見て、データを受信していれば以下のような処理を行う。
- 受信完了後はふつうのWiFiステーションとして振る舞わせるため、まずesp_now_deinit(); によってESP-NOW動作を停止する。この後Webサーバーへのデータ送信が完了するまでコントローラ側の送信は失敗することになる。
- 送信側MACアドレスが初見なら、MACアドレス、受信時刻、受信シーケンス番号をrx_log配列に追加する。正しい受信データとして送信処理を行う。
- すでにrx_log内にあるMACアドレスからの受信ならば、前回受信時のシーケンス番号と今回のそれを比較する。同一シーケンス番号ならば、不要な再送と見なしてデータを廃棄する。そうでなければ、送信処理に進む。
不要な再送は、コントローラ側が失敗と見なして再送したものの、実は直前データをスレーブ側で正しく受信できていたときに生じる。
不要な再送に関する処理は、同タイミングでの重複した送信データを排除する処理になるが、今回の実装では送信ごとに新しい測定データが来るだけなので実は気にする必要はない。ただ、そのうち複数フレームにわたるデータを扱うかもしれないので入れてみた。コントローラ4台を1分間隔で動かしているような場合、現在のスケッチでは記録に残らないが12時間に1回程度は不要な再送が行われていた。
データを受信すると、WiFiステーションとしてデータをWebサーバー送信するためsend_to_server(); を呼ぶ。そしてstart_espnow(); でESP-NOWを再開しloop() の先頭に戻る。
bool ap_connect(); と bool send_to_server();
ESP8266やESP32をWiFiステーションとして使うときの典型的なコードで、静的IPアドレスをもつクライアントとしてWiFiルーター(アクセスポイント)に接続し、HTTP GETリクエストでrx_data の内容をWebサーバーに送信する。ESP32を使っても、アクセスポイントに接続するのに3秒ちょっとかかってしまう。うちのWiFiルーターを新しくすると速くなるのかもしれないが。
各データは、リクエストパラメータ名と同じ名前のデータベースカラムに格納される。リクエストパラメータのX1は送信都度ごとのWiFi稼働時間、X2は送信成功までのリトライ回数をもつ。例えば、
select avg(x1) from envdata10 where post_datetime between start_timestamp and last_timestamp;
といったシンプルなSQL文で、指定期間におけるコントローラの平均稼働時間を得ることができる。
データベースの定義や構造は従来と同様にした。ただ、コントローラごとに別のテーブルに格納するようにしたので、測定データ格納用スクリプト(store_data.php) の内容は変更し今回のスケッチ類とともに掲載した。
コントローラ側概略
回路など
ESP-NOWを使うからと行って特別に回路に工夫がいるわけでない。コントローラ側は今までと同様にESP-WROOM-02(D)に温度センサーBME280 / BMP280を使った。WROOM-02無印2台と”D”付き2台を使ったが、”D”の方が早いということはなかった。スイッチサイエンスのフル版変換基板に載せたWROOM-02Dを使ったボードでは、実験的に+3.0V出力の定電圧レギュレータMCP-1702-300 を使っている。
フル版変換基板の下に抵抗やワイヤを配置することで主要部分は左半分に収まった。右端に見えるTO-92パッケージの3本足がMCP1702で、その周辺の抵抗は電圧測定のための分圧用。RSTピンはGPIO16にのみ接続し、手動リセットやUARTダウンロードの開始はENにつながったスイッチで行う。
MCP1702の定格出力電流は最大250mAでWROOM-02での使用には?が付くのだが、静止時電流が5μA max ということで、スリープ時の消費電流を抑えるために使ってみることにした。
ディープスリープ中の電流を、ボードのGNDと電池のーの間にテスターをはさんで測ってみると約35μAだった。分圧回路に10μA、WROOM-02に20μAとするとほぼ計算通りであり、NJU7223F33を使ったときから約25μA改善している。
スケッチやこの記事を書き始めてから1週間ほど稼働させているが安定して動いている(ように見える)。問題は電池がヘタってきたときWROOM-02開始時の電流でどれだけドロップアウトするかだと思うが、電池電圧が4V未満になるまでには長い時間がかかりそうである。
ESP8266のドキュメントに従えば 、WROOM-02に与える電源電圧を+3.0Vにしたので、esp_init_data_default.bin の107番目に33 ではなく30と書く必要があるようだ。RF動作の最適化のためということだが、とりあえず気にしないことにした。
動作およびスケッチ
測定間隔時間ごとにディープスリープから復帰し、測定と送信を行った後にディープスリープに入る。リトライしない場合の所用時間は個体差があって約20~25msecだった。今までWiFi接続から送信まで最短でも3秒間はかかっていたので、WiFi稼働時の平均消費電流は単純に考えて1/100以下となる。
25msecの内訳は、スリープ復帰から送信直前までが23msecで、送信結果を取得して最後にrtcメモリを更新するまが2msec程度だった。なお、ディープスリープ復帰以外(電源オン、手動/ソフトウェアリセット)では、電源投入時のBME280の安定化のためにdelay(10) を入れているから10msec余計にかかる。
個々のコントローラごとにユニークな識別子などをプログラムに入れておく必要もないので、ESP8266 Arduino Core (2.4.1)を使ってビルドした同じプログラムをすべてのコントローラにダウンロードした。ただ、リトライ再送時の時間調整のため、MACアドレスをシードとした擬似乱数を使っている。
おもなデータ
uint8_t slave_mac[] = {…};
この変数にはスレーブ側のMACアドレスをバイナリで指定する。このスレーブに対してのみ送信を行う。スレーブとして使うESP32を変更した際にはすべてのコントローラに対して同じバイナリをダウンロードし直すのが面倒かもしれない。別ページに掲載したスケッチでは一部伏せ字としていることに注意。
unsigned long start_time = millis();
電源オン、ディープスリープ、手動リセット等による復帰時の擬似的な時刻( millis() )を記憶するための変数。こう書いておくとsetup(); が呼ばれる前に値が設定される。
enum { S_WAIT = 0, S_SUCCESS , S_RETRY, S_TIMEOUT, S_FAILED } state = S_WAIT;
send_data(); 関数の戻り値の定義。データ送信の成功不成功やタイムアウトの判定に用いている。
おもな関数
void setup();
コントローラ側は繰り返し処理がないので setup() 内で完結している。リトライ時なども一旦ディープスリープに入り、次回起動時に処理を続行する。状態を維持するため、スレーブ側と同じ sensor_data_t 構造体をRTCメモリ内に保持する。
開始すると、ESP.getResetInfoPtr(); によってリスタートした理由を得て、ディープスリープ復帰ならばRTCメモリからsensor_data_t 型のデータを変数dataに読み込むし、そうでないならdataを初期化する。電源オン、手動リセット、スケッチによるリスタートなどが有りうる。
RSTピンにリセットスイッチを接続している構成では、ディープスリープ中にリセットスイッチを押しても復帰とリセットの区別が付かないので、上の回路のようにENピンにリセットスイッチを接続している。また、RTCから読み出したデータが破損していたときも dataを初期化する。
次に、ステーションのMACアドレス ( WiFi.macAddress(sta_mac)) を得てArduinoの擬似乱数列生成用のシードとしている。random()で得られる値はリトライ時や再送があった際の測定間隔の調整のために使うもので、コントローラごとにユニークな値になってくれればよい。
1 2 3 4 5 |
if (esp_now_init() != ESP_OK) die("ESPNow Init Failed"); esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER); if ( esp_now_add_peer(slave_mac, ESP_NOW_ROLE_SLAVE, WIFI_CHANNEL, NULL, 0) != ESP_OK) die("esp_now_add_peer() failed."); |
によってESP-NOWを初期化する。die(); はLEDを点滅させながら無限ループする関数でコーディング途上にのみ有用。
そしてsend_data(); を呼び、センサーを使った測定とデータの送信を行う。この関数が返した送信結果に応じて以下のように処理する。
- S_SUCCESS
送信が成功したので、次回送信のためにシーケンス番号(data.seq_no)をインクリメントとし、data.retry = 0; により送信成功状態とし、スリープ時間(sleep_ms)に測定間隔をセットする。ただし、再送を経ての送信成功ならば、他のコントローラの送信タイミングと近いはずなので次回測定を少し遅らせる。 - S_RETRY
送信失敗を検出したのでdata.retry++; してリトライ状態とする。そして random() で得られた時間(1~2秒)をsleep_msにセットして次回起動時に再送させる。
リトライ状態が10回連続したらあきらめてESP.restart(); する。 - S_TIMEOUT
send_data(); 内で送信後1秒たっても送信結果が得られなかったので、今回はなかったことにしてsleep_ms に10000をセットする。S_RETRYと同様に処理してもいいかもしれない。 - S_FAILED
send_data(); 内でのAPI呼出しの失敗を表す。
送信失敗を10回繰り返す原因としてはスレーブ側の電源がオフになっている、とか、スケッチを入れ替えているとかが考えられる。ESP.restart() すれば最初からやり直しになるのだが、長めのスリープをいれてやる方が電池に優しいかもしれない。
最後に、dataの内容をRTCメモリに書き出し、sleep_ms 期間のディープスリープに入る。
int send_data(sensor_data_t* p);
まずはセンサーインタフェース用のbme280オブジェクトを使って測定を行い送信のためにデータのチェックサムをセットする。そして、
esp_now_register_send_cb(onSent);
によって送信終了時のコールバック関数をセットし、
esp_now_send(slave_mac, (u8*)p, sizeof(sensor_data_t));
で送信を行う。このAPIがエラーを返したら、S_FAILEDをリターンする。
送信実行後、コールバック関数のonSent() によって変数 state の値が更新されるのを待ち、esp_now_unregister_send_cb(); によってコールバック関数を解除してからstateの値をリターンする。stateの値が1秒以内の更新されなければ、S_TIMEOUT をリターンする。
その他
電池のモチ
1時間に10回、つまり6分間に一度測定と送信を行い、再送無しで理想的に動作した場合に消費する電池容量について、従来方式と比較すると以下のようになる。従来方式では1回の測定と送信に3秒かかるものとした。
- 従来方式: WiFi稼働時間は30秒間、(30 × 90 ÷ 3600) + 0.06 = 0.81mAh。約98日間稼働。
- ESP-NOW方式: WiFi稼働時間は0.25秒間、(0.25 × 90 ÷ 3600) + 0.06 = 0.066mAh。約1200日間稼働。
(WiFi稼働時90mA、スリープ時60μA、電池容量を1900mAhとして算出)。
ここまで稼働時間を短縮できると、電源電圧測定のための分圧抵抗に常時流れる10μAがもったいない。+3.0VのMCP1702を使い分圧抵抗をなくした場合、スリープ時の電流は25μAとなるはずなので、1時間あたりに使う電池容量は0.031mAhとなり、計算上の稼働日数は約2554 (days) ≒ 7 (years) となる。電池自体の自己放電もあるから、ここまではいかないだろうけど。
もっとも、ESP32側は常時通電しているわけなので、トータルとしての電力消費は増えているわけだが。
再送タイミング
前から分かっていることだが、ESP8266のディープスリープは指定した秒数よりもちょっと早くウェイクアップしてくる。しかも個体差があるようで、データベーステーブルに溜まっていくデータを見てみると、徐々に各コントローラからのデータ格納時刻の差が縮まってきて再送が生じていることが分かる。
そのため、再送が必要になったときは少しずつスリープ時間を延ばすことで対処してみた。複数のコントローラが同時に再送状態になったとき、おのおのが同じ時間だけ送信タイミングをずらしたのでは再送状態を抜け出せないと考えられるので、random(); によって幅を与えてみた。
各コントローラを開始するタイミングが最初からずれていると、なかなか再送にならないので、しばらくスレーブ側を止めて全コントローラが一斉に送信を試みる状況を作って動作を確認した。
有線LANを使いたい
スレーブ側は、データをサーバーに送る前にESP-NOWを停止し通常のWiFiステーションとして動作する。したがって、この間(約3秒間)は受信不能状態となる。コントローラ側がこのタイミングにデータを送ろうとすると必然的に送信エラーになる。コントローラの数が増えていけば、受信不能時間がどんどん増えてしまうのは必定。
この3秒間がもったいないので、WiFi.mode(WIFI_STA_AP); として、最初にルーターに接続してからESP-NOWを開始してみたのだが、ESP-NOWのAPI呼出し( esp_now_init(); など)は成功するもののコントローラからの送信はすべて失敗してしまう。今のところ通常のWiFiステーションとESP-NOWスレーブの動作は排他的に見える。
コントローラ1台につき3秒間のESP-NOW不感時間をなくすための方法としては、スレーブ側はSPI接続の有線LANでインターネットに出れるようにしておくのが良さそうに思える。どうせACアダプタを使っているし、ESP32のパワーは有り余っている。毎度アクセスポイント接続するよりも速やかにサーバーへの送信が終わることが期待できる。
シリアルモニタ出力の抑止
小ネタとしてコントローラ、スレーブともに、シリアルモニタ関係を以下のようなマクロで定義している。
1 2 3 4 5 6 7 8 9 10 11 12 |
#define SERIAL_MONITOR 1 #if SERIAL_MONITOR #define START_MSG "\n" + String(__FILE__) + " start.\n" #define SERIAL_INIT(a) Serial.begin(a) #define SERIAL_OUT(a) Serial.print(a) #define SERIAL_F(...) Serial.printf(__VA_ARGS__) #else #define START_MSG #define SERIAL_INIT(a) #define SERIAL_OUT(a) #define SERIAL_F(...) #endif |
#define SERIAL_MONITO 0 とすれば、Serialの宣言も初期化もなくなるし、モニタ出力用の呼び出しも消えてなくなるからメモリの節約になる。
コントローラ側にはそれ以上の効果があって、データベーステーブルのX1カラムに格納される稼働時間 (sensor_data_t.elapsed) が約6~7msec 短縮される。つまり、WiFi稼働時間を20%程度減らすことができる。スレーブ側では大した意味をもたないが、電池で動かすコントローラ側では価値があるだろう。当然ながら、シリアルケーブルを接続していない状態での話。
きょうのまとめ
ちょっと前に書いた、「WROOM-02とBME280 電池駆動で温度測定の改訂版」の続きとして、温度変化の評価によってWiFiを使う回数を減らして電池のモチをよくする話を書いていたのだけど、ESP-NOW を試してみたら劇的にWiFi稼働時間を短縮できたのでこちらを先に掲載した。温度変化を評価する方式は3月の初めから2ヶ月ほど動いているが、電源電圧の低下度合いは期待通りにゆるやかである。ESP-NOWと組み合わせることで、電池の本数を減らすとか、もっと容量の小さな電池を使うといった方向性が見えてくる。
今のところ1時間あたり60回も測定と送信をおこなっているから、1週間安定して動いてくれれば10,000行以上のデータが溜まるはずである。ただ、ボードの場所を変えるときに電源ケーブルが抜けるとかブレッドボードに刺したケーブルの接触が悪化するとかの理由で電源電圧が低下して止まってしまったりするのが困りもの。
いちおうスケッチが固まったのである程度連続稼働させ、データがたまった時点でリトライの発生頻度やリトライを含めた平均稼働時間といった統計的なものを追記していく予定です。
追記1(有線LAN)
ESP-WROOM-02DにSPI-EthernetモジュールのUSR-ES1を接続し、ESP8266用のESP-NOWスレーブを実装してみたところ、期待通りにサーバーへのデータ転送時間を短縮できた。loop(); 内での受信検出からサーバーへの送信完了までの所用時間は100msec前後になった。
ESP-NOWのスレーブ機能についても、ESP32用とほぼ同じスケッチで動作した。Ethernetモジュール+ESP8266用のESP-NOWスレーブについては別稿としてまとめる予定です。