ESP32S3 ESP-NOW通信

2026.8.24 2026.8.2 Coskx Lab  

1 はじめに

ESPデバイス間の通信に特化したESP-NOWで,一対のXiao WSP32S3でUart風に通信します。
ESP-NOWでロボットを無線リモートコントロールすることを想定しています。
リモートコントロールではUIを備えたアプリが必要なので,それらはPCやスマホに任せることにしました。
そのため,
  ロボットに搭載したESP32S3
   <-> ESP-NOW通信
   <-> 中継器ESP32S3
   <-> USBシリアル通信
   <-> アプリ搭載デバイス
という構成で考えることなります。




ESP-NOW通信を行う一対のESP32は,server/clientのような関係ではなく,対等の関係ですが,ここでは想定した構成上,呼び方で
 Central(中継器側)
 Peripheral(ロボット側)
という区別をします。

なお,将来的にはよいUIを備えたESP32デバイスを使って,
  中継器ESP32S3
   <-> USBシリアル通信
   <-> アプリ搭載デバイス
のところを置き換える構成も考えられます。


2 ラッピングクラスの採用

ラッピングクラスを採用し,プログラムコード記述の負担を軽減しています。
ラッピングクラスでは,文字列受信チェックにおいてポーリング方式,コールバック方式のどちらも使えるようにしました。
ラッピングクラスでは,送信時のエラーチェックが必要な場合は,コールバック方式で行うようにしています。

ラッピングクラスは次の2点を補っています。
(1)受信時にMACアドレスフィルタを設け,通信対象以外のMACアドレスデバイスからの受信データを破棄しています。
(2)送信直後に送信ミスを感知した場合は,クラス内手続きで再送信しています。


3 使用環境

4 ESP-NOWデモプログラム(コールバック方式)

Central側とPeripheral側の2つのプログラムが必要ですが,テスト用なので1つにまとめています。
プログラムコードの先頭部分にある
#define CENTRAL
の行が有効だったらCentral側としてコンパイルされ,そうでなかったらPeripheral側としてコンパイルされるようにしました。

テストなのでCentral側ESP32S3とPeripheral側ESP32S3をそれぞれPCのターミナルアプリにつないで,シリアル通信で監視できるようにしました。
(実用段階ではPeripheral側はシリアル通信で監視するわけにはいきません。)
Centralから"Hello"のような文字列をPeripheralに送ると,Peripheralは送られてきた文字列をオウム返しにCentralに送り返します。
Centralから文字列"go"が送られてくると,Peripheralは一秒間隔でデバイス時計でのmsec時刻をCentralに送信する動作を開始します。
Centralから文字列"stop"が送られてくると,Peripheralは時刻送信をやめます。

プログラムコードには通信相手のMACアドレスを記述する必要があります。(Central側のコードではPeripheral側のMACアドレスを記述します。)
MACアドレスはすべての情報デバイスにあらかじめ割り当てられていますが,どこにも書いてありません。
ArduinoIDEでプログラムをESP32などに転送する時の「出力」の黒い画面の最初の方に転送先のデバイスのMACアドレスが表示されるのでわかります。

プログラム中には2つのコールバック関数があります。
 void StringReceptionReport(String str)
  文字コードを通信相手から受信したときに呼び出されます。
  引数の文字列strが受信文字列です。
  受信文字列はオウム返しで相手に送信されます。
 void transmissionFailureReport()
  送信操作sendString()直後に,送信が失敗したときに
  呼び出されます。

この2つのコールバック関数は,setup()中でラッピングクラスEspNowUARTのインスタンスuartの初期化のときに設定しておく必要があります。
 uart.setCallbackFunc(StringReceptionReport);
 uart.setCallbackFuncSndNG(transmissionFailureReport);

setup()中のクラスEspNowUARTのインスタンスuartの初期化のときに行うべき設定があと2つあります。
 uart.setPartnerMac(partner_mac);
  通信相手のMACアドレスの設定
  (自分のMACアドレスは設定する必要はありません)
 uart.setChannel(ESPNOW_WIFI_CHANNEL);
  使用する電波2.4GHz帯域のチャンネル番号
  1から14までのどれか任意の値ですが,1,6,11が推奨されています。
  ここではESPNOW_WIFI_CHANNELで1に設定されています。

クラスEspNowUARTの定義はEspNowUART.hとEspNowUART.cppに記述されています。
クラス定義を含むESPNOWデモプログラムのファイルは次からダウンロードできます。
3つすべてのファイルを同一作業フォルダに入れて作業してください。

    クラス定義を含むESPNOWデモプログラムのダウンロード


  ESPNOWデモプログラム EspNow_DEMO.ino

//EspNow_DEMO.ino

/*
* Aiming to remotely control the robot via a serial terminal
on a PC or smartphone.
* Communication involves the bidirectional exchange of text
data on a peer-to-peer basis, similar to UART.
* Envision the following two types of device configurations.
(1) ESP32-S2 (Robot) <-> ESP-NOW <-> ESP32-S2 (Repeater)
    <-> USB Serial cable <-> PC or Smartphone
(2) ESP32-S2 (Robot) <-> ESP-NOW <-> ESP32-S2 (Controller)
* Referring to the ESP32-S2 (robot) as the "peripheral" 
and the ESP32-S2 (repeater or Controller) as the "central."
* Will operate in Station mode on the specified channel.
*/

#include <EspNowUART.h>

// Enabling the next line, the program code generates a "Central" program;
// otherwise, a "Peripheral" program.
#define CENTRAL

// Channel to be used by the ESP-NOW protocol (1 .. 14)
#define ESPNOW_WIFI_CHANNEL 1

// Sets the MAC address of the communication partner.
#ifdef CENTRAL
const uint8_t partner_mac[] = {0x84, 0xfc, 0xe6, 0x78, 0x1e, 0xdc}; //84:FC:E6:78:1E:DC peripheral (Peripheral)
const String selfintroduction = "This is Central.";
#else     //In the case of Peripheral
const uint8_t partner_mac[] = {0x48, 0x27, 0xe2, 0xe6, 0xdb, 0xc4}; //48:27:E2:E6:DB:C4 central (Central)
const String selfintroduction = "This is Peripheral.";
#endif

EspNowUART uart;
int EspNowStatus = -1;

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

  uart.setIntroduction(selfintroduction);
  uart.setPartnerMac(partner_mac);
  uart.setChannel(ESPNOW_WIFI_CHANNEL);
  uart.setCallbackFunc(StringReceptionReport);
  uart.setCallbackFuncSndNG(transmissionFailureReport);
  EspNowStatus = uart.begin(true); // true: show Openning message 
  String status[] = {"started.", "has error on begin.", "has erroe on add_peer."};
  Serial.println("ESPNOW Uart " + status[EspNowStatus]);
  #ifndef CENTRAL
  pinMode(LED_BUILTIN, OUTPUT);
  #endif
}

bool requested = false;

#ifdef CENTRAL
void loop() {
  if (Serial.available()) {
    String str = Serial.readStringUntil('\n');
    str.trim();
    if (uart.sendString(str)) Serial.println("failed on uart.sendString()");
    else Serial.println("I believe I sent '" + str + "'.");
  }
  delay(100);
}
#else     //In the case of Peripheral
void loop() {
  static int blink = 0;
  if (EspNowStatus == 0) {
    blink = 1 - blink; // togle blink
    digitalWrite(LED_BUILTIN, blink&1);
  }
  if (requested) {
    uint32_t currenttime = millis();
    String str = String(currenttime);
    if (uart.sendString(str)) Serial.println("failed on uart.sendString()");
    else Serial.println("I believe I sent '" + str + "'.");
  }
  delay(1000);
}
#endif

//受信時に呼び出されるコールバック関数
void StringReceptionReport(String str) {
  str.trim(); //不要な文字コードを削除
  Serial.println("RX_Value = " + str);
  #ifndef CENTRAL   //In the case of Peripheral
  //開発初期の確認用エコーバック 不要になったらコメントアウトして送信量を減らす
  if (uart.sendString(str)) Serial.println("failed on uart.sendString()");
  if (str.indexOf("go") == 0) requested = true;
  else if (str.indexOf("stop") == 0) requested = false;
  #endif
}

//送信に失敗したことがわかったときに呼び出される関数
void transmissionFailureReport() {
  Serial.println("!!!Sending Failed. Then resent.!!!");
}

5 実行の様子

Centralにはシリアルターミナルアプリが動作するPCを次の図のように接続し,テストしました。
ただし,ログ取得のために,PeripheralにもPCをつないでいます。
WindowsPC用シリアルターミナルアプリは,「Serial_Terminal_plus」を使っています。
(https://github.com/healthywalk/Serial_Terminal_plus)



両方のデバイスを起動した後,Centralからメッセージ"bonjour"を送信し,Peripheralは"RX_Value = bonjour"と表示した後すぐにオウム返しでメッセージ"bonjour"を送信しています。Centralは受信した内容をRX_Value = bonjour"と表示しています。
Centralからメッセージ"Hola"を送信したときに,送信ミスが発生しました。クラス内手続きで再送信しているため,Peripheralは"RX_Value = Hola"と表示した後すぐにオウム返しでメッセージ"Hola"を送信し,Centralは受信した内容をRX_Value = Hola"と表示しています。時間は戻るのですが,Centralは送信直後にコールバックで送信ミスを感知し,再送後,コールバックで"!!!Sending Failed. Then resent.!!!"を表示しています。

参考 再送信の機能が組み込まれる前の動作
Centralから一つ目のメッセージ"go"を送信したつもりだけれど,送信ミスが感知されています。このメッセージはPeripheralも受信していません。
Centralからメッセージ"go"を再び送信し,Peripheralが受信すると,そのあと1秒間隔でPeripheralはデバイス内時計の時刻値(msec)をメッセージとしてCentralに送信し続けます。
Centralからメッセージ"stop"を送信し,Peripheralが受信すると,時刻値送信は終了します。



2つのログでは,たまたま通信開始直後に送信ミスを感知しました。これまでの通信テストでは,数十回に一回くらいは送信ミスが生じているように感じました。
(WiFiと同じ2.4GHz帯でCH1を使っていたのでWiFiからの干渉が考えられるのですが,電波チェックではCH1は空いていましたので,不思議な感じでした。)


6 送信ミスについて

ESP-NOWはMACレイヤーを使った手順無しの通信であるため,送信ミスを起こしても自動的に再送はしてくれません。しかし,送信ミスは感知できてtransmissionFailureReport()が呼び出されてきます。
(送信ミスを告げるコールバックに対して対処していないプログラムでは,送信ミスに気づくことはできません。)
クラスEspNowUART内では,送信時に,送信内容を保存しておき,送信ミスが生じたら,保存しておいた内容を自動的に再送信しています。
そのため,プログラミングにおいて,送信ミスのことは考慮する必要はありません。

上記のデモプログラムでは,送信ミスの発生をターミナル画面で確認するために,コールバック関数transmissionFailureReport()を使用しています。(コールバック関数transmissionFailureReport()では通信に関する操作は何もしていません。)

実用プログラムにおいては,送信ミスの感知および再送処理を行ったことを確認する必要はありません。そのため,コールバック関数transmissionFailureReport()を作る必要はありませ。またsetup時にこの関数を登録する必要もありません。


7 1対1通信を確実にする

ESP-NOWでは仕様上1対1通信は確実ではありません。 予期していないデバイスが,1対1通信中のどちらかのデバイスのMACアドレスあてにメッセージを送信してくると,受け取ってしまいます。(マルチキャスト通信ではこの方が便利なのかもしれません。)



受信時のMACアドレスフィルタリングは自分で行なわなければなりません。
メッセージ受信時には,送り手のMACアドレスも通知されてくるので, クラスEspNowUARTでは,送り手のMACアドレスフィルタリング(意図しないMACアドレスからのメッセージは破棄)を実装し,1対1通信を確実にしています。


8 まとめ

ESP-NOWで1対1のUART風通信に特化したラッピングクラスを作成し,テストを行いました。
ラッピングクラスは次の2点を補っています。
(1)受信時にMACアドレスフィルタを設け,通信対象以外のMACアドレスデバイスからの受信データを破棄しています。
(2)送信直後に送信ミスを感知した場合は,クラス内手続きで再送信しています。

ラッピングクラスでは送信エラー報告をコールバック方式で実装しました。 また,受信時処理はポーリング方式,コールバック方式のどちらでも使えるように実装しました。
(上記プログラムではコールバック方式を採用しています。)
テスト中に送信エラーを幾度となく検出しました。送信ミスの許されない用途においては送信ミスのときの再送機構(クラスに実装済)は必須と思われます。




補足 コールバック方式とポーリング方式

受信時の扱いはコールバック方式とポーリング方式があります。

〇コールバック方式では,setup()で受信時に呼び出してもらう関数の名前を登録しておき, 受信時には,他の作業とは無関係にその関数を呼び出してもらいます。
その関数は引数に文字列変数を持っていて,その文字列が受信した文字列です。
その関数内には最小限必要な作業を記述します。
受信頻度が低い場合にはこの方式が都合がよいです。

〇ポーリング方式ではsetup()ではなにもしませんが,loop()中で,受信しているかどうかを専用関数で問い合わせ, 受信していたら改めて受信文字列を別の専用関数で受け取って必要な作業を行います。


付録1

クラスEspNowUARTを用いて,ポーリング方式のプログラムコードEspNow_DEMO2.inoを作成しました。
使用しているクラスEspNowUARTのファイルは本文と同じものです。
送信ミス時の再送信およびMACアドレスフィルタリングは舞台裏(クラスEspNowUART)で行われています。
クラス定義を含むESPNOWデモプログラムのファイルは次からダウンロードできます。
3つすべてのファイルを同一作業フォルダに入れて作業してください。

    クラス定義を含むESPNOWデモプログラムのダウンロード

  ESPNOWデモプログラム(EspNow_DEMO2.ino)

//EspNow_DEMO2.ino

/*
* Aiming to remotely control the robot via a serial terminal
on a PC or smartphone.
* Communication involves the bidirectional exchange of text
data on a peer-to-peer basis, similar to UART.
* Envision the following two types of device configurations.
(1) ESP32-S2 (Robot) <-> ESP-NOW <-> ESP32-S2 (Repeater)
    <-> USB Serial cable <-> PC or Smartphone
(2) ESP32-S2 (Robot) <-> ESP-NOW <-> ESP32-S2 (Controller)
* Referring to the ESP32-S2 (robot) as the "peripheral" 
and the ESP32-S2 (repeater or Controller) as the "central."
* Will operate in Station mode on the specified channel.
*/

#include <EspNowUART.h>

// Enabling the next line, the program code generates a "Central" program;
// otherwise, a "Peripheral" program.
//#define CENTRAL

// Channel to be used by the ESP-NOW protocol (1 .. 14)
#define ESPNOW_WIFI_CHANNEL 1

// Sets the MAC address of the communication partner.
#ifdef CENTRAL
const uint8_t partner_mac[] = {0x84, 0xfc, 0xe6, 0x78, 0x1e, 0xdc}; //84:FC:E6:78:1E:DC peripheral (Peripheral)
const String selfintroduction = "This is Central.";
#else     //In the case of Peripheral
const uint8_t partner_mac[] = {0x48, 0x27, 0xe2, 0xe6, 0xdb, 0xc4}; //48:27:E2:E6:DB:C4 central (Central)
const String selfintroduction = "This is Peripheral.";
#endif

EspNowUART uart;
int EspNowStatus = -1;

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

  uart.setIntroduction(selfintroduction);
  uart.setPartnerMac(partner_mac);
  uart.setChannel(ESPNOW_WIFI_CHANNEL);
  EspNowStatus = uart.begin(true); // true: show Openning message 
  String status[] = {"started.", "has error on begin.", "has erroe on add_peer."};
  Serial.println("ESPNOW Uart " + status[EspNowStatus]);
  #ifndef CENTRAL
  pinMode(LED_BUILTIN, OUTPUT);
  #endif
}

bool requested = false;

#ifdef CENTRAL
void loop() {
  if (Serial.available()) {
    String str = Serial.readStringUntil('\n');
    str.trim();
    if (uart.sendString(str)) Serial.println("failed on uart.sendString()");
    else Serial.println("I believe I sent '" + str + "'.");
  }
  if (uart.ReceivedStringReady()) { //Polling
    Serial.println(uart.getReceivedString());
  }
  delay(100);
}
#else     //In the case of Peripheral
void loop() {
  static int blink = 0;
  if (EspNowStatus == 0) {
    blink = 1 - blink; // togle blink
    digitalWrite(LED_BUILTIN, blink&1);
  }
  if (uart.ReceivedStringReady()) { //Polling
    processReceivedString(uart.getReceivedString());
  }
  if (requested) {
    uint32_t currenttime = millis();
    String str = String(currenttime);
    if (uart.sendString(str)) Serial.println("failed on uart.sendString()");
    else Serial.println("I believe I sent '" + str + "'.");
  }
  delay(1000);
}
#endif

void processReceivedString(String str) {
  str.trim(); //不要な文字コードを削除
  Serial.println("RX_Value = " + str);
  #ifndef CENTRAL   //In the case of Peripheral
  //開発初期の確認用エコーバック 不要になったらコメントアウトして送信量を減らす
  if (uart.sendString(str)) Serial.println("failed on uart.sendString()");
  if (str.indexOf("go") == 0) requested = true;
  else if (str.indexOf("stop") == 0) requested = false;
  #endif
}

受信しているかどうかを専用関数で問い合わせ,受信していたら改めて受信文字列を別の専用関数で受け取るという作業は,loop()内の
   if (uart.ReceivedStringReady()) { //Polling
     processReceivedString(uart.getReceivedString());
   }
のところです。
関数ReceivedStringReady()は,受信していたらtrueを返す関数です。
かっこの中は
   String str = uart.getReceivedString();
   processReceivedString(str);
と書いてあるのと同じです。

付録2

プリプロセッサ指令(条件付きコンパイルの指示)で2つのプログラムを1つにしたものは見にくいので,Central側とPeripheral側をそれぞれ作成しました。
受信時の扱いはポーリング方式を利用しています。
使用しているクラスEspNowUARTのファイルは本文と同じものです。
送信ミス時の再送信およびMACアドレスフィルタリングは舞台裏(クラスEspNowUART)で行われています。
クラス定義を含むESPNOWデモプログラム(Central側とPeripheral側の2つ)のファイルは次からダウンロードできます。
Central側およびPeripheral側において,それぞれ,3つすべてのファイルを同一作業フォルダに入れて作業してください。

    クラス定義を含むESPNOWデモプログラムのダウンロード