arduino-esp32 v3でのESP-NOW研究 その2 ピア追加と暗号化通信

概要

前回はブロードキャストでの通信をしたので、ピアを追加してからのユニキャスト通信を試してみたいと思います。

スケッチ例

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

#define ESPNOW_WIFI_CHANNEL 4  // 1 - 14

#define ESPNOW_ADVERTISING_STRING "ADVERTISING_ESP32_ESPNOW_STRING"

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) {
    if (broadcast && memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0) {
      // ADVERTISING Echo
      this->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    } else {
      // Communication
      const uint8_t *mac = addr();
      Serial.printf("Peer Receive(%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 = nullptr;
MY_ESP_NOW_Peer *espnow_peer = nullptr;

void espnow_receive(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
  if ((espnow_peer == nullptr) && (memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0)) {
    // Add peer
    Serial.println("Add peer");
    espnow_peer = new MY_ESP_NOW_Peer(info->src_addr, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);
    espnow_peer->Begin();

    // ADVERTISING Echo
    espnow_peer->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Peer Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  }
  Serial.printf("Non Peer 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(2000);

  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 = new MY_ESP_NOW_Peer(ESP_NOW.BROADCAST_ADDR, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);
  espnow_peer_broadcast->Begin();
}

void loop() {
  static int cnt = 0;

  if (espnow_peer == nullptr) {
    // ADVERTISING
    espnow_peer_broadcast->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Broadcast Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  } else {
    // Communication
    char data[100];

    cnt++;
    size_t len = snprintf(data, sizeof(data), "Unicast Send(%s) Cnt = %d", ARDUINO_BOARD, cnt);
    espnow_peer->Send((uint8_t *)data, len + 1);
    Serial.printf("Peer Send[%s] : %s\n", ARDUINO_BOARD, data);
  }

  delay(5000);
}

ざっくりですがスケッチ例になります。前回からの変更点を中心に処理順に解説をしていきたいと思います。全体の流れとしては相手募集中のアドバタイズ用文字列を送受信して、文字列が一致している場合にピアを登録する処理になっています。

ピア管理

MY_ESP_NOW_Peer *espnow_peer_broadcast = nullptr;
MY_ESP_NOW_Peer *espnow_peer = nullptr;

ブロードキャストでピアする相手を探すためのespnow_peer_broadcastと、実際のピアを設定するespnow_peerを宣言しています。通信先のMACアドレスをコンストラクタで渡す必要があるため、動的に変わる相手の場合にはポインタで宣言をして、相手のMACアドレスが確定したところでnewをする必要があります。

espnow_peer_broadcastはポインタで宣言する必要はないのですが、espnow_peerと同じ呼び出し方にしたかったのでまずはポインタで宣言しています。

espnow_peer_broadcastの初期化

  espnow_peer_broadcast = new MY_ESP_NOW_Peer(ESP_NOW.BROADCAST_ADDR, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);
  espnow_peer_broadcast->Begin();

ブロードキャストアドレスでクラスを作成しています。複数のピアを管理する場合には個別の変数にする以外に配列やリストなどで管理することが可能ですが、今回は変数単位で管理しました。

アドバタイズパケットの送信

#define ESPNOW_ADVERTISING_STRING "ADVERTISING_ESP32_ESPNOW_STRING"

250文字までのアドバタイズを表す文字列を設定しています。この文字列が一致した相手とピアを組むようにしています。

  if (espnow_peer == nullptr) {
    // ADVERTISING
    espnow_peer_broadcast->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Broadcast Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  }

loopの中でピアがない場合にはアドバタイズ文字列を送信する処理を行っています。

アドバタイズ文字列受信時にピアに登録

void espnow_receive(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
  if ((espnow_peer == nullptr) && (memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0)) {
    // Add peer
    Serial.println("Add peer");
    espnow_peer = new MY_ESP_NOW_Peer(info->src_addr, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);
    espnow_peer->Begin();

    // ADVERTISING Echo
    espnow_peer->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Peer Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  }
  Serial.printf("Non Peer 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);
}

上記のピア以外からのメッセージ受信にてピアが設定されておらず、自分と同じアドバタイズ文字列の場合にピアを登録して、念の為相手にアドバタイズ文字列を送信しています。

この状態でピアがお互いに認識している状態であり、以降はピア以外からのブロードキャストは何も処理をしない状態になります。

ピアへの送信

  if (espnow_peer == nullptr) {
    // ADVERTISING
    espnow_peer_broadcast->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Broadcast Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  } else {
    // Communication
    char data[100];

    cnt++;
    size_t len = snprintf(data, sizeof(data), "Unicast Send(%s) Cnt = %d", ARDUINO_BOARD, cnt);
    espnow_peer->Send((uint8_t *)data, len + 1);
    Serial.printf("Peer Send[%s] : %s\n", ARDUINO_BOARD, data);
  }

loop関数の中の処理ですが、ピアがなかったらアドバタイズ文字列の送信、それ以外はピアに実際の通信を送信している処理になります。

ピアからの受信

  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    if (broadcast && memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0) {
      // ADVERTISING Echo
      this->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    } else {
      // Communication
      const uint8_t *mac = addr();
      Serial.printf("Peer Receive(%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);
    }
  }

ピアからパケットを受信した場合にはonReceive関数が呼び出されます。このときブロードキャストでアドバタイズ文字列を送信している場合には再起動などで相手を見失った場合になります。アドバタイズ文字列を送り返してあげて、再度ピアに登録してもらいます。それ以外の場合にはピアからの実際のユニキャスト通信になるはずです。

通信を暗号化する

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

#define ESPNOW_WIFI_CHANNEL 4  // 1 - 14

#define ESPNOW_ADVERTISING_STRING "ADVERTISING_ESP32_ESPNOW_STRING"
#define ESPNOW_PMK_STRING "pmk1234567890123"
#define ESPNOW_LMK_STRING "lmk1234567890123"
#define ESPNOW_TIMEOUT (10000)

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() {
    remove();
  }
  bool Begin() {
    lastReceive = millis();
    return add();
  }
  unsigned long lastReceive;
  bool Send(const uint8_t *data, size_t len) {
    return send(data, len);
  }
  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    if (broadcast && memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0) {
      // ADVERTISING Echo
    } else {
      // Communication
      lastReceive = millis();
      const uint8_t *mac = addr();
      Serial.printf("Peer Receive(%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 = nullptr;
MY_ESP_NOW_Peer *espnow_peer = nullptr;

void espnow_receive(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
  if ((espnow_peer == nullptr) && (memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0)) {
    // Add peer
    Serial.println("Add peer");
    espnow_peer = new MY_ESP_NOW_Peer(info->src_addr, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, (const uint8_t *)ESPNOW_LMK_STRING);
    espnow_peer->Begin();

    // ADVERTISING Echo
    espnow_peer_broadcast->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Peer Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  }
  Serial.printf("Non Peer 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(2000);

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

  if (!ESP_NOW.begin((const uint8_t *)ESPNOW_PMK_STRING)) {
    ESP.restart();
  }

  ESP_NOW.onNewPeer(espnow_receive, NULL);

  espnow_peer_broadcast = new MY_ESP_NOW_Peer(ESP_NOW.BROADCAST_ADDR, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);
  espnow_peer_broadcast->Begin();
}

void loop() {
  static int cnt = 0;

  if (espnow_peer == nullptr) {
    // ADVERTISING
    espnow_peer_broadcast->Send((const uint8_t *)ESPNOW_ADVERTISING_STRING, strlen(ESPNOW_ADVERTISING_STRING) + 1);
    Serial.printf("Broadcast Send[%s] : %s\n", ARDUINO_BOARD, ESPNOW_ADVERTISING_STRING);
  } else {
    // Timeout Check
    if (ESPNOW_TIMEOUT < (millis() - espnow_peer->lastReceive)) {
      // Timeout reset
      ESP.restart();
    }

    // Communication
    char data[100];
    cnt++;
    size_t len = snprintf(data, sizeof(data), "Unicast Send(%s) Cnt = %d", ARDUINO_BOARD, cnt);
    espnow_peer->Send((uint8_t *)data, len + 1);
    Serial.printf("Peer Send[%s] : %s\n", ARDUINO_BOARD, data);
  }

  delay(5000);
}

上記がサンプルスケッチとなります。

暗号化キーを設定する

#define ESPNOW_PMK_STRING "pmk1234567890123"
#define ESPNOW_LMK_STRING "lmk1234567890123"

PMKが全体での共有キーで、LMKが端末単位で設定する暗号化キーになります。この2つの組み合わせで暗号化をするのですが端末ごとにキーを分けるのは結構たいへんなので通常は全部同じ設定でも問題ないはずです。

暗号化キーを設定してESP-NOWを初期化

  if (!ESP_NOW.begin((const uint8_t *)ESPNOW_PMK_STRING)) {
    ESP.restart();
  }

beginに共通キーを設定して初期化します。

ピア追加時にキーを登録

  if ((espnow_peer == nullptr) && (memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0)) {
    // Add peer
    Serial.println("Add peer");
    espnow_peer = new MY_ESP_NOW_Peer(info->src_addr, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, (const uint8_t *)ESPNOW_LMK_STRING);
    espnow_peer->Begin();

こちらにはLMKのボード別のキーを設定します。これだけで通信が暗号化されます。ただし、非常に面倒な処理が追加で必要となります。

タイムアウト処理の追加

  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    if (broadcast && memcmp(ESPNOW_ADVERTISING_STRING, data, len) == 0) {
      // ADVERTISING Echo
    } else {
      // Communication
      lastReceive = millis();
      const uint8_t *mac = addr();
      Serial.printf("Peer Receive(%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);
    }
  }

受信時にlastReceiveを更新して、最終受信時間を保存しておきます。

    // Timeout Check
    if (ESPNOW_TIMEOUT < (millis() - espnow_peer->lastReceive)) {
      // Timeout reset
      ESP.restart();
    }

loopの中でタイムアウトのチェックをして、一定時間受信していない場合には強制的に再起動させてアドバタイズまで状態を戻します。このため実際の運用時には通信が発生していない場合にも定期的にECHO的なパケットを送信する必要があります。

またクラスの作り方に問題があるのですが、通信相手が再起動した場合にピア済みなのでonReceiveでブロードキャストのアドバタイズ文字列を受信するのですが、そのままアドバタイズ文字列を返送しようと思っても暗号化されて送信されるので、受信側はピア状態でないので暗号化されたパケットは受信失敗します。

暗号化したピアの場合には、お互いにピア登録済みである状態でしか通信ができません。ブロードキャストで送信することでお互いに通信が可能なのですが、onReceive関数の中からブロードキャスト用クラスにアクセスするのは設計上どうかと思うので正規パケットの受信タイムアウト処理にしてみました。ただsetKeyを使って暗号化を解除してからアドバタイズ文字列をユニキャストで送信することは可能だと思います。

送信と受信の関係まとめ

送信方法送信結果受信側状態
①ブロードキャスト常に成功電源OFFの場合受信せず
②ブロードキャスト常に成功Peer未登録の場合onNewPeerで受信
③ブロードキャスト常に成功Peer登録済の場合onReceiveで受信
④ユニキャスト(Peer) 暗号化無失敗電源OFFの場合受信せず
⑤ユニキャスト(Peer) 暗号化無成功Peer未登録の場合onNewPeerで受信
⑥ユニキャスト(Peer) 暗号化無成功Peer登録済の場合onReceiveで受信
⑦ユニキャスト(Peer) 暗号化有失敗電源OFFの場合受信せず
⑧ユニキャスト(Peer) 暗号化有成功Peer未登録の場合受信せず
⑨ユニキャスト(Peer) 暗号化有成功Peer登録済で暗号一致の場合onReceiveで受信
⑩ユニキャスト(Peer) 暗号化有成功Peer登録済で暗号不一致の場合受信せず

ブロードキャストの場合には常に送信は成功します。①はだれも受け取っていない場合でも送信は成功になります。②はPeer外の場合にはonNewPeerで、③はPeer済みなのでonReceiveで受信しています。ちなみに暗号化の設定をしてもブロードキャストの場合には無視されて暗号化無しでの送信となります。

ユニキャストの場合には送信成功したかは電波が届いたかの状態となります。本当に届いているかはわからないので注意してください。④は相手がいない場合なので送信失敗となります。⑤と⑥はPeer状態によって受信関数が変わるだけです。

暗号化ありの場合にはちょっとわかりにくいです。⑦は相手がいないので送信失敗で単純です。⑧はPeer登録していない場合にはonNewPeerで受信するのではなく、暗号化キーの未設定扱いとなり受信失敗します。⑨は正常に暗号化通信ができる成功。⑩は暗号化キーが違うので受信できません。

ここで問題になるのが⑧と⑩は受信側では暗号化キー不一致で受信していないのですが、送信側は成功扱いになっています。送信側は電波が相手に届いたかのチェックなので、本当に受信できているのかはわかりません。セキュリティー的に暗号化キーが違うとわかってしまうとブルートフォース攻撃をされる可能性があるのかもしれませんが注意してください。

ちなみに送信成功チェックはvoid onSent(bool success)をESP_NOW_Peerクラスで実装することで確認可能です。

まとめ

EPS-NOWは暗号化が可能で、暗号化することで意図しないパケットを受信するのを防ぐことができます。ただ実際には受信して捨てているだけの内部動作になります。

ブロードキャストだとまわりにいるすべての端末に届き、その端末が混信してしまう可能性があるのでなるべくユニキャストを使ったほうが良さそうです。

アドバタイズの仕組みも若干工夫が必要で、ある程度プロジェクトごとに独自の文字列を設定して混信しないようにするのがよいと思います。今回はアドバタイズ募集と返信で同じ文字列を利用していますが親側がアドバタイズをして、子供側が親のアドバタイズを発見したらユニキャストで別の参加用文字列を送信するなどをしてピアを組むなどもできると思います。さらに通信の中身を見られたくない場合には暗号化をする流れだと思います。

コメント