AtomS3CamとAtomS3RでESP-NOW無線映像転送してみた
⚠️ 2026/06/11 追記:platformio.ini を修正しました
送信側の platformio.ini に以下の問題がありました。
board = m5stack-atoms3 → esp32-s3-devkitc-1 に変更(公式推奨)
board_build.arduino.memory_type = opi_opi → qio_opi に変更(Octal Flash非対応で起動しない場合がある)
CONFIG_SPIRAM_MODE_OCT / CONFIG_SPIRAM_SPEED_80M → 削除(qio_opiで自動設定される)
-DARDUINO_USB_CDC_ON_BOOT=1 / -DARDUINO_USB_MODE=1 を追加(シリアル出力に必要)
旧設定の board = m5stack-atoms3 + opi_opi はボード定義がFlashモードを補正するため動作する場合がありますが、環境によっては Octal Flash option selected, but EFUSE not configured! で起動しません。
AtomS3Camで撮影した映像を、ESP-NOWでAtomS3Rに飛ばして液晶に表示する。
有線なし、WiFiルーターなし、ケーブルなし。2台を近くに置くだけで動く。
結果は約5fps。ロボットの監視カメラや簡易FPVとして実用的なラインに届いた。

用意するもの
- AtomS3Cam(送信側)
- AtomS3R(受信側・LCD表示)
- USBケーブル × 2(書き込み・シリアルモニター用)
- PC(PlatformIO使用)

PlatformIOの設定
動作確認:M5Unified 0.2.x、espressif32 platform
AtomS3Camは8MB PSRAMを搭載しているが、デフォルト設定では有効にならない。PSRAMが使えないとカメラ映像のJPEG変換が途中でメモリ不足になって失敗する。
送信側(AtomS3Cam)の platformio.ini
⚠️ 2026/06/11 追記:platformio.ini を修正しました
[env:atoms3r-cam]
platform = espressif32@6.7.0
board = esp32-s3-devkitc-1
framework = arduino
upload_port = COM18 ; 環境に合わせて変更
monitor_port = COM18
monitor_speed = 115200
lib_deps =
m5stack/M5Unified@^0.2.0
; PSRAMを有効にする設定(これがないとJPEG変換がメモリ不足で落ちる)
build_flags =
-DESP32S3
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
board_build.arduino.memory_type = qio_opi
monitor_flags =
--raw
--dtr 1
--rts 1
現時点で再確認した結果のものです。
受信側(AtomS3R)の platformio.ini
[env:m5stack-atoms3]
platform = espressif32
board = m5stack-atoms3
framework = arduino
upload_port = COM19 ; 環境に合わせて変更
monitor_port = COM19
lib_deps =
m5stack/M5Unified@^0.2.0
build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
monitor_flags =
--raw
--dtr 1
--rts 1
MACアドレスの確認方法
ESP-NOWは相手のMACアドレスを直接指定する。WiFi起動時にシリアルに表示されるMACアドレスを使うこと。パッケージに印刷されているMACアドレスとは異なる場合がある。
まず下記のスケッチを両方のデバイスに焼いてMACアドレスを確認する。
#include <M5Unified.h>
#include <WiFi.h>
void setup() {
M5.begin();
Serial.begin(115200);
WiFi.mode(WIFI_STA);
}
void loop() {
Serial.print("MAC: ");
Serial.println(WiFi.macAddress());
delay(3000);
}
注意: setup()内に書くとUSB-CDCの接続タイミングによりシリアルに表示されないことがある。loop()に書いて3秒ごとに出力させるのが確実。
手順:
- このスケッチをAtomS3Camに焼き、シリアルモニターを開いてMACをメモ
- そのMACを受信側コードの
senderMAC[]に書く - 同じスケッチをAtomS3Rに焼き、MACをメモ
- そのMACを送信側コードの
receiverMAC[]に書く
コード
送信側(AtomS3Cam)
#include <M5Unified.h>
#include <esp_now.h>
#include <WiFi.h>
#include "esp_camera.h"
#include "esp_wifi.h"
// AtomS3Cam カメラピン定義
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 21
#define SIOD_GPIO_NUM 12
#define SIOC_GPIO_NUM 9
#define Y9_GPIO_NUM 13
#define Y8_GPIO_NUM 11
#define Y7_GPIO_NUM 17
#define Y6_GPIO_NUM 4
#define Y5_GPIO_NUM 48
#define Y4_GPIO_NUM 46
#define Y3_GPIO_NUM 42
#define Y2_GPIO_NUM 3
#define VSYNC_GPIO_NUM 10
#define HREF_GPIO_NUM 14
#define PCLK_GPIO_NUM 40
#define POWER_GPIO_NUM 18 // カメラ電源制御ピン
// 受信側(AtomS3)のMACアドレスに書き換える
uint8_t receiverMAC[] = {0xXX, 0xXX, 0xXX, 0xXX, 0xXX, 0xXX};
bool streaming = false;
void onSent(const uint8_t *mac_addr, esp_now_send_status_t status) {}
void onReceive(const uint8_t *mac_addr, const uint8_t *data, int len) {
String cmd = String((char*)data);
cmd.trim();
if (cmd == "stream") {
streaming = true;
} else if (cmd == "stop") {
streaming = false;
}
}
void setup() {
M5.begin();
Serial.begin(115200);
delay(3000); // USB-CDC接続待ち
Serial.println("Ready");
// カメラ電源ON(LOWで電源が入る)
pinMode(POWER_GPIO_NUM, OUTPUT);
digitalWrite(POWER_GPIO_NUM, LOW);
delay(500);
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.xclk_freq_hz = 20000000;
// AtomS3CamのセンサーはJPEGネイティブ非対応
// RGB565で取得してframe2jpgで変換する
config.pixel_format = PIXFORMAT_RGB565;
config.frame_size = FRAMESIZE_QQVGA; // 160x120
// PSRAMにフレームバッファを確保する
// CAMERA_FB_IN_DRAMにするとframe2jpgのmallocが失敗するので注意
config.fb_count = 2;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.grab_mode = CAMERA_GRAB_LATEST;
// これを入れないと "SCCB init err" が出る
Wire.end();
Wire1.end();
if (esp_camera_init(&config) != ESP_OK) {
Serial.println("カメラ初期化失敗");
return;
}
Serial.println("カメラOK");
WiFi.mode(WIFI_STA);
WiFi.disconnect();
Serial.print("MAC: ");
Serial.println(WiFi.macAddress());
if (esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW初期化失敗");
return;
}
esp_now_register_send_cb(onSent);
esp_now_register_recv_cb(onReceive);
// 受信側をpeerに登録(channel=0で現在チャンネルに追従)
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, receiverMAC, 6);
peer.channel = 0;
peer.encrypt = false;
esp_now_add_peer(&peer);
esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);
Serial.println("ESP-NOW OK");
}
void loop() {
static uint32_t last_frame = 0;
// 220ms = 約5fps が安定ライン
// 200ms以下にするとパケットが混線して映像が乱れる
const uint32_t FRAME_INTERVAL = 220;
if (streaming) {
uint32_t now = millis();
if (now - last_frame < FRAME_INTERVAL) return;
last_frame = now;
camera_fb_t *fb = esp_camera_fb_get();
if (fb) {
uint8_t *jpg_buf = nullptr;
size_t jpg_len = 0;
if (frame2jpg(fb, 80, &jpg_buf, &jpg_len)) {
// JPEGを240バイトのチャンクに分割してESP-NOWで送信
// ESP-NOWは1パケット最大250バイトまでの制限がある
const size_t CHUNK = 240;
size_t offset = 0;
uint8_t pkt[242];
while (offset < jpg_len) {
size_t chunk_len = min(CHUNK, jpg_len - offset);
pkt[0] = (offset == 0) ? 0x01 : 0x00; // 0x01=先頭, 0x00=中間
pkt[1] = (uint8_t)chunk_len;
memcpy(&pkt[2], jpg_buf + offset, chunk_len);
esp_now_send(receiverMAC, pkt, chunk_len + 2);
delay(2);
offset += chunk_len;
}
// 終端パケット(0xFF)で受信側に完了を通知
pkt[0] = 0xFF;
pkt[1] = 0;
esp_now_send(receiverMAC, pkt, 2);
free(jpg_buf);
jpg_buf = nullptr;
} else {
if (jpg_buf) {
free(jpg_buf);
jpg_buf = nullptr;
}
delay(500);
}
esp_camera_fb_return(fb);
}
}
}
受信側(AtomS3R)
#include <M5Unified.h>
#include <esp_now.h>
#include <WiFi.h>
#include "esp_wifi.h"
// 送信側(AtomS3Cam)のMACアドレスに書き換える
uint8_t senderMAC[] = {0xXX, 0xXX, 0xXX, 0xXX, 0xXX, 0xXX};
#define MAX_JPEG_SIZE (30 * 1024)
static uint8_t jpeg_buf[MAX_JPEG_SIZE];
static size_t jpeg_len = 0;
static bool jpeg_ready = false;
void onReceive(const uint8_t *mac_addr, const uint8_t *data, int len) {
if (len < 2) return;
uint8_t flag = data[0];
uint8_t chunk_len = data[1];
if (flag == 0x01) {
// 先頭パケット:バッファリセット
jpeg_len = 0;
jpeg_ready = false;
}
if (flag == 0xFF) {
// 終端パケット:受信完了
jpeg_ready = true;
return;
}
if (jpeg_len + chunk_len < MAX_JPEG_SIZE) {
memcpy(jpeg_buf + jpeg_len, data + 2, chunk_len);
jpeg_len += chunk_len;
}
}
void setup() {
auto cfg = M5.config();
cfg.internal_imu = false;
M5.begin();
Serial.begin(115200);
delay(3000);
Serial.println("Ready");
M5.Display.setTextSize(2);
M5.Display.println("ESP-NOW RX");
WiFi.mode(WIFI_STA);
WiFi.disconnect();
Serial.print("MAC: ");
Serial.println(WiFi.macAddress());
if (esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW初期化失敗");
return;
}
esp_now_register_recv_cb(onReceive);
// 送信側をpeerに登録
// ESP-NOWはsend前にpeer登録が必要
// これがないと "stream" コマンドが相手に届かない
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, senderMAC, 6);
peer.channel = 0;
peer.encrypt = false;
if (esp_now_add_peer(&peer) != ESP_OK) {
Serial.println("peer登録失敗");
}
esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);
Serial.println("ESP-NOW OK");
M5.Display.println("Waiting...");
}
void loop() {
static bool rx_active = false;
M5.update();
if (M5.BtnA.wasPressed()) {
rx_active = !rx_active;
if (rx_active) {
uint8_t msg[] = "stream";
esp_now_send(senderMAC, msg, sizeof(msg));
M5.Display.fillScreen(TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.println("Streaming...");
} else {
uint8_t msg[] = "stop";
esp_now_send(senderMAC, msg, sizeof(msg));
jpeg_ready = false;
M5.Display.fillScreen(TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.println("Stopped");
}
}
if (jpeg_ready) {
jpeg_ready = false;
if (!rx_active) return;
// fillScreen()を省くとチラつきが減る
M5.Display.drawJpg(jpeg_buf, jpeg_len, 0, 0);
}
}
つまずきポイントまとめ
1. PSRAMが有効になっていない
board_build.arduino.memory_type = opi_opi と build_flags の設定が抜けていると、フレームバッファがDRAMに確保されてframe2jpgのmallocが失敗する。起動時に PSRAM ID read error が出るが、これは正常(AtomS3CamはPSRAMが使えているときでも出る)。
2. fb_location を DRAM にしない
CAMERA_FB_IN_DRAM にするとframe2jpgが数回で落ちる。CAMERA_FB_IN_PSRAM にすること。
3. peer登録を両側でやる
ESP-NOWはsend()する前に相手をpeer登録する必要がある。送信側だけでなく受信側もpeer登録が必要。これがないと受信側から送るコマンドが相手に届かない。
4. MACアドレスはシリアルで確認する
パッケージ印刷のMACと、WiFi起動時のMACが異なる場合がある。必ずシリアルモニターで確認すること。
5. FRAME_INTERVALは220ms以上にする
200ms以下にするとframe2jpgの変換時間+送信時間がインターバルを超えて、次のフレームの先頭パケットが割り込んで受信バッファがリセットされる。220msで約5fpsが安定ライン。
6. Wire.end() / Wire1.end() を呼ぶ
これがないと SCCB init err が出てカメラが初期化できない。
結果
- 解像度:QQVGA(160×120)
- フレームレート:約5fps
- 遅延:約200ms
- 通信方式:ESP-NOW(WiFiルーター不要)
ロボットに載せて手元のM5Stackに映像を飛ばす用途で十分実用的なラインに届いた。
Discussion