arduino-esp32 v3でのESP-NOW研究 その1 基本動作

概要

ESP32のESP-NOWですが、arduino-esp32のバージョンが3になり大幅に使い方が変わったので使い方を調べてみました。内容的に思ったよりいろいろな使い方があるので複数回に分割したいと思います。

ESP-NOWとは?

Wi-Fiを利用した通信なのですが、アクセスポイントを利用せずに端末同士で通信をするプロトコルです。ESP8266とESP32シリーズだけが利用できる独自通信なのですが非常にお手軽に利用することができます。

非常に軽い通信プロトコルなのですが、1度に250バイトまでのデータしか送信することができません。再送機能などもないので自分である程度通信を制御する必要があります。通信はMACアドレスを利用して送受信を行うため、近くにいる端末全部が送信するブロードキャスト(FF:FF:FF:FF:FF:FF)と、MACアドレスを指定して送信するユニキャストを意識して使い分ける必要があります。

今回arduino-esp32が3系にバージョンアップしたことにより、ベースとなるESP-IDFの関数を直接呼び出す方式から、Arduino用のライブラリにラッピングされているクラスを利用する形に変更になっています。実際のところESP-IDFの関数を直接呼び出し方が使いやすいのですが、今回はクラスを使う正規の方法を確認していきます。

ベースの処理

#include "ESP32_NOW.h"
#include "WiFi.h"

#define ESPNOW_WIFI_CHANNEL 4 // 1 - 14

class MY_ESP_NOW_Peer : public ESP_NOW_Peer {
public:
  MY_ESP_NOW_Peer(const uint8_t *mac_addr, uint8_t channel, wifi_interface_t iface, const uint8_t *lmk)
    : ESP_NOW_Peer(mac_addr, channel, iface, lmk) {}
  ~MY_ESP_NOW_Peer() {
  }
  bool Begin() {
    return add();
  }
  bool Send(const uint8_t *data, size_t len) {
    return send(data, len);
  }
  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    const uint8_t *mac = addr();
    Serial.printf("MY_ESP_NOW_Peer (%02X:%02X:%02X:%02X:%02X:%02X) %s : %s\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], (broadcast ? "Broadcast" : "Unicast"), data);
  }
};

MY_ESP_NOW_Peer espnow_peer_broadcast(ESP_NOW.BROADCAST_ADDR, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);

void espnow_receive(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
  Serial.printf("espnow_receive (%02X:%02X:%02X:%02X:%02X:%02X) : %s\n", info->src_addr[0], info->src_addr[1], info->src_addr[2], info->src_addr[3], info->src_addr[4], info->src_addr[5], data);
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  WiFi.mode(WIFI_STA);
  WiFi.setChannel(ESPNOW_WIFI_CHANNEL);
  while (!WiFi.STA.started()) {
    delay(100);
  }

  if (!ESP_NOW.begin()) {
    ESP.restart();
  }

  ESP_NOW.onNewPeer(espnow_receive, NULL);

  espnow_peer_broadcast.Begin();
}

void loop() {
  static int cnt = 0;
  cnt++;
  char data[100];

  snprintf(data, sizeof(data), "send cnt = %d", cnt);
  espnow_peer_broadcast.Send((uint8_t *)data, strlen(data) + 1);
  Serial.printf("Send : %s\n", data);
  delay(5000);
}

ブロードキャストで周りにいる端末すべてに通信を送信するスケッチ例です。このコードを元に個別の動きを確認していきたいと思います。

通信チェンネル

#define ESPNOW_WIFI_CHANNEL 4 // 1 - 14

Wi-Fiは1から14までのチャンネルがあります。このチャンネルを合わさないと通信ができないので、他の端末との混信を防ぐためにある程度変更したほうがよいと思います。

ただし手元で確認したところ4と6チャンネルのように2つ離していてもたまに混信していました。電波なのである程度周波数の山があり、隣り合っているところにも近いと混信してしまうのですがごく近距離の場合には3つぐらい離さないと混信してしまう感じでした。

ただし、基本的には不要なパケットも届く前提で、届いたパケットは必要かを判定してから処理をするようにしましょう。

ESP_NOW_Peerクラス

class MY_ESP_NOW_Peer : public ESP_NOW_Peer {
public:
  MY_ESP_NOW_Peer(const uint8_t *mac_addr, uint8_t channel, wifi_interface_t iface, const uint8_t *lmk)
    : ESP_NOW_Peer(mac_addr, channel, iface, lmk) {}
  ~MY_ESP_NOW_Peer() {
  }
  bool Begin() {
    return add();
  }
  bool Send(const uint8_t *data, size_t len) {
    return send(data, len);
  }
  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    const uint8_t *mac = addr();
    Serial.printf("MY_ESP_NOW_Peer (%02X:%02X:%02X:%02X:%02X:%02X) %s : %s\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], (broadcast ? "Broadcast" : "Unicast"), data);
  }
};

ここが厄介なところなのですが、通信をする場合に通信相手をピア(Peer)と呼びます。ピア・ツー・ピア(Peer to Peer)などで使われている単語です。このピアを設定しないとESP-NOWで通信できない設定になっています。そしてピアを設定するためにはESP_NOW_Peerクラスを継承して独自クラスを宣言する必要があります。

  MY_ESP_NOW_Peer(const uint8_t *mac_addr, uint8_t channel, wifi_interface_t iface, const uint8_t *lmk)
    : ESP_NOW_Peer(mac_addr, channel, iface, lmk) {}

上記のコンストラクタは固定で、引数を省略する場合以外はほぼこのままの処理をいれる必要があります。

  ~MY_ESP_NOW_Peer() {
  }

デストラクタも宣言が必要です。動的に管理する場合にはremove関数を呼び出して、ピアから解除する必要があります。通常は空で問題ないはずです。

  bool Begin() {
    return add();
  }

内部的にはadd関数を呼び出すとピアが有効化されます。しかしながらprotectedで宣言されているのでなにか関数を作成してその中から呼び出す必要があります。コンストラクタだとまだESP-NOWが初期化されていないのでだめです。

  bool Send(const uint8_t *data, size_t len) {
    return send(data, len);
  }

同じくsend関数で送信するのですがprotectedなので呼び出せません。無駄にラッパーが増えます。

  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    const uint8_t *mac = addr();
    Serial.printf("MY_ESP_NOW_Peer (%02X:%02X:%02X:%02X:%02X:%02X) %s : %s\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], (broadcast ? "Broadcast" : "Unicast"), data);
  }

受信した場合のコールバック関数になります。登録済みのピアから受信した場合にはこの関数が呼び出されます。

ピアの宣言

MY_ESP_NOW_Peer espnow_peer_broadcast(ESP_NOW.BROADCAST_ADDR, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);

ここもあまり好きではないのですがコンストラクタでMACアドレスを渡している関係でソースの中にMACアドレスを書き込まないと変更できません。ポインタやリストなどで宣言をしてnewをする形でないと動的にMACアドレスを指定できないので注意してください。

今回はブロードキャストアドレスを使って近くにいる端末すべてに通信を飛ばします。そのためespnow_peer_broadcastのonReceive関数は呼び出されることはありません。

ピア以外からの受信コールバック

void espnow_receive(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
  Serial.printf("espnow_receive (%02X:%02X:%02X:%02X:%02X:%02X) : %s\n", info->src_addr[0], info->src_addr[1], info->src_addr[2], info->src_addr[3], info->src_addr[4], info->src_addr[5], data);
}

ピアからの受信はESP_NOW_Peerを継承したクラスのonReceive関数なのですが、ピアに登録していない場合には個別のコールバック関数が呼び出されます。ここでピアに動的に追加する処理をすることでブロードキャストなどでピアを探して登録するなどの処理が可能になります。

setup関数

void setup() {
  Serial.begin(115200);
  delay(1000);

  WiFi.mode(WIFI_STA);
  WiFi.setChannel(ESPNOW_WIFI_CHANNEL);
  while (!WiFi.STA.started()) {
    delay(100);
  }

  if (!ESP_NOW.begin()) {
    ESP.restart();
  }

  ESP_NOW.onNewPeer(espnow_receive, NULL);

  espnow_peer_broadcast.Begin();
}

基本的な初期化部分になります。

  Serial.begin(115200);
  delay(1000);

シリアルの初期化。

  WiFi.mode(WIFI_STA);
  WiFi.setChannel(ESPNOW_WIFI_CHANNEL);
  while (!WiFi.STA.started()) {
    delay(100);
  }

上記でWi-Fiの初期化を行っています。WiFi.begin関数などを使ってWi-Fiアクセスポイントに接続しつつESP-NOWを利用することもできます。

  if (!ESP_NOW.begin()) {
    ESP.restart();
  }

ESP-NOWを開始させます。

  ESP_NOW.onNewPeer(espnow_receive, NULL);

ピア以外から受診したものを処理するコールバック関数を登録します。

  espnow_peer_broadcast.Begin();

ピアを開始します。内部的にはここでピアの登録処理が走ります。

loop関数

void loop() {
  static int cnt = 0;
  cnt++;
  char data[100];

  snprintf(data, sizeof(data), "send cnt = %d", cnt);
  espnow_peer_broadcast.Send((uint8_t *)data, strlen(data) + 1);
  Serial.printf("Send : %s\n", data);
  delay(5000);
}

5秒間隔で文字列を送信するサンプルです。注意点としては文字列の長さ+1で終端のNULLも送信しないと受信側で文字列の終わりが処理できなくなります。本当はsnprintf関数の戻り値がNULL終端を含んだ文字数なので安全です。

内部処理

送信時は登録済みのピアに対してのみ送信が可能です。マルチキャストとユニキャストであまり差はありません。ただしユニキャストの場合にはパケットが届いたかの確認が可能ですが、マルチキャストの場合には常に送信成功となります。

受診時はピア登録済みのMACアドレスからの通信はESP_NOW_Peerを継承したクラスのonReceive関数が呼び出され、未登録のピアからはESP_NOW.onNewPeer関数で登録したコールバック関数が呼び出されます。

昔は共通のコールバック関数だったのでMACアドレスを自分で調べて処理をする必要がありましたが、ある程度整理されていますが使いやすくなったのかは微妙なところです。

まとめ

全般的にESP-NOWがクラスでラッピングされたのですが、まだこなれていない印象を受けました。スケッチ例としては「ESP_NOW_Broadcast_Master」と「ESP_NOW_Broadcast_Slave」の内容になりますが親と子の関係性になっているので少しわかりにくかったです。

ESP-NOW自体はあまり親と子という関係性ではなく、お互いが対等なピアの通信になります。ただ実際の運用の場合には1台を親にして、周りの子供を検索してお互いに登録しあうみたいな処理も必要になることも多いとは思います。

コメント