🎥

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秒ごとに出力させるのが確実。

手順:

  1. このスケッチをAtomS3Camに焼き、シリアルモニターを開いてMACをメモ
  2. そのMACを受信側コードの senderMAC[] に書く
  3. 同じスケッチをAtomS3Rに焼き、MACをメモ
  4. その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);
  }
}

https://x.com/i/status/2055189578533965924


つまずきポイントまとめ

1. PSRAMが有効になっていない
board_build.arduino.memory_type = opi_opibuild_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