PR

【第7回】ESP-NOW で電池をさらに持たせる — 受信役の ESP を 1 台立てる

環境センサーデータサーバーシステム
記事内に広告が含まれています。

前回、ESP-WROOM-02 から Raspberry Pi の Flask へ WiFi + HTTP POST でデータを送りました。動いてはいますが、電池運用として見ると無駄があります。

起きている時間のほとんどが「WiFi につなぐ待ち時間」なのです。

第6回のシリアルログを思い出してください。温度を測るのに 1 秒、POST は 0.1 秒、それに対して WiFi の接続確立に 2〜3 秒かかっていました。電池は「起きている時間 × 起きる回数」で減るので、この 2〜3 秒が寿命を決めています。

今回はもう 1 つの選択肢、ESP-NOW を扱います。WiFi の接続手順を丸ごと省いて、数十ミリ秒で送りっぱなしにする方式です。


  1. この記事の構成 — 何を検証したか
  2. 1. なぜ受信役が必要なのか
  3. 2. 用意するもの
    1. 受信役に XIAO ESP32-C3 を選ぶ理由
  4. 3. Pi 側の受け皿を先に作る
    1. 3.1 pyserial を入れる
    2. 3.2 受信テスト用スクリプト
    3. 3.3 ポート名の確認 — ここが第6回と違うところ
    4. 3.4 dialout グループの確認
  5. 4. Master の最小スケッチ — まず MAC を知る
    1. 4.1 ボード設定
    2. 4.2 最小 Master スケッチ
    3. 4.3 書き込んで MAC を控える
  6. 5. 子機の最小スケッチ
    1. 5.1 masterMAC の書き換え
    2. 5.2 動かす
    3. 5.3 Pi につないでみる
    4. 6.1 ESP-NOW は「WiFi の下の層」を直接使う
    5. 6.2 構造体をそのまま送るということ
    6. 6.3 送信結果「成功」の意味に注意
  7. 7. 本番版へ — 何を足すか
    1. 7.1 ESP32-C3 の DeepSleep は配線不要
    2. 7.2 電池電圧の測り方 (子機)
    3. 7.3 Master は寝かせない
  8. 8. 本番スケッチ全文
    1. 8.1 Master (XIAO ESP32-C3、USB で Pi に接続)
      1. Master が Pi に流すもの
    2. ⚠️ 書き込む前に必ず読んでください — DeepSleep 中は書き込めません
    3. 8.2 子機 (XIAO ESP32-C3 + DS18B20、電池駆動)
      1. 書き込む前に変える 3 か所
  9. 9. よくある失敗
    1. 9.1 最大の罠 — Master を交換したとき
  10. 10. 電池はどのくらい変わるか
  11. 11. 到達距離について
  12. 12. AI との会話例
    1. コツ 1: 構造体を先に固定して渡す
    2. コツ 2: 関数の一部ではなく、ファイル全体を出してもらう
    3. コツ 3: ボードパッケージのバージョンを伝える
  13. 13. ここまでのまとめ
  14. 次回予告

この記事の構成 — 何を検証したか

先に、どこまで実機で確かめたかを書いておきます。

この記事の構成は実機で動作を確認済みです。 子機 (XIAO ESP32-C3) が ESP-NOW で送信し、受信役にもう 1 枚の XIAO ESP32-C3 を使って受け取り、USB シリアル経由で Raspberry Pi に渡すところまで、end-to-end で通っています。

受信役に ESP32 (無印) を使う構成でも動作実績があります。本記事で XIAO ESP32-C3 を選んだ理由は後述します。両者で Pi 側に見えるシリアルポート名が変わるので、そこは本文中で「確認ポイント」として明示しています。


1. なぜ受信役が必要なのか

第6回の構成はこうでした。

[ESP8266] ── WiFi + HTTP ──→ [Raspberry Pi の AP] ──→ Flask

ESP が Pi の AP に「WiFi 端末として」接続していました。だから IP アドレスが必要で、接続の確立に時間がかかっていたわけです。

ESP-NOW はこうなります。

[XIAO ESP32-C3 子機] ── ESP-NOW ──→ [XIAO ESP32-C3 受信役] ── USB ──→ [Raspberry Pi]
                        (無線・直接)                          (シリアル)

ESP-NOW は ESP 同士が直接やりとりする方式です。WiFi の認証も IP も使いません。その代わり、Pi は ESP-NOW を喋れません。Pi にあるのは普通の WiFi と Ethernet だけです。

そこで「ESP-NOW を受け取って、USB シリアルで Pi に流し込む」だけの ESP を 1 台立てます。これが受信役 (以下 Master) です。

価格面でもESP-NOWは利点があります。USB WiFi ドングル (Archer T2U Plus、3,000 円前後) より XIAO ESP32-C3 (1,500 円前後) の方が安いのです。

理由は電波の到達距離です。Archer T2U Plus は長いアンテナが付いていて、実用的な距離が稼げます。ESP モジュールの小さなアンテナとは前提が違います。

この連載では選択肢を残す作りにしています。

  • 距離が要る / 台数が少ない → 第4〜6回の WiFi + AP 構成
  • 距離は近い / 電池を長く持たせたい → 今回の ESP-NOW 構成
  • 混在も可能 — Pi 側は両方同時に受けられます

「どちらか一方が正解」ではありません。現場を見て選ぶための材料を両方そろえる、というのがこの連載の立て付けです。


2. 用意するもの

品目数量目安価格備考
Seeed Studio XIAO ESP32-C311,500 円受信役 (Master) 用。第2回で使ったものとは別に 1 枚
USB Type-C ケーブル (データ通信対応)1500 円充電専用ケーブルでは通信できません

子機側は第2回で組んだ XIAO ESP32-C3 + DS18B20 をそのまま使います。追加の配線はありません。

受信役に XIAO ESP32-C3 を選ぶ理由

筆者の実機環境では、受信役に Amazon で買った無印の ESP32 開発ボードを使っています。技適番号は基板に 211-161007 と印字されていますが、製造元の記載がシルクにありません。

211-161007 は ESP32-WROOM-32 の工事設計認証番号です。番号自体は実在するものですが、製造元がたどれない個体を記事で「これを買ってください」とすすめるのは無責任だと考えました。

第2回でも書いたとおり、技適の確実さを優先するなら公式 (Seeed Studio) / スイッチサイエンス / 秋月電子から買うのが安全です。XIAO ESP32-C3 の工事設計認証番号は 204-B00704 で、これは第2回で確認済みです。

同じボードを 2 枚使えば、Arduino IDE のボード設定も 1 種類で済みます。読者が手間を減らせる、それだけで利点です。


3. Pi 側の受け皿を先に作る

第6回と同じ順番で行きます。受け側を先に用意してから、送る側を作ります。 そうしないと「送ったつもり」なのか「受けられていない」のかが切り分けられません。

3.1 pyserial を入れる

Pi に SSH で入り、以下を実行します。

sudo apt update
sudo apt install -y python3-serial

pip install pyserial ではなく apt を使ってください。Raspberry Pi OS Trixie (Debian 13) は外部管理環境なので、pip は明示的に拒否されます。

apt で python3-serial(pyserial)をインストールする画面

3.2 受信テスト用スクリプト

nanoエディターでserial_test.pyというファイルを作成します。

nano ~/serial_test.py
nano エディタで serial_test.py を新規作成する画面

以下を貼り付けます。

import serial

PORT = "/dev/ttyACM0"

ser = serial.Serial(PORT, 115200, timeout=5)
print(f"{PORT} を開きました。待機中... (Ctrl+C で終了)", flush=True)

while True:
    line = ser.readline().decode("utf-8", errors="replace").strip()
    if line:
        print("受信:", line, flush=True)

errors="replace" を入れているのは、ESP がリセットした瞬間に文字化けしたバイトが飛んでくることがあり、そこで例外を出して止まるのを防ぐためです。

serial_test.py のコードを貼り付けた nano エディタ

Ctrl + O で上書き

nano エディタで Ctrl + O による上書き保存

Enter で実行

nano エディタのファイル名確認で Enter

Ctrl + X でエディター終了

3.3 ポート名の確認 — ここが第6回と違うところ

まだ Master は作っていないので、この時点では実行しても何も出ません。 先にポート名だけ確認しておきます。

USB を挿していない状態で:

ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null

何も出なければ正常です。この状態を覚えておいてください。

確認ポイント: ttyACM か ttyUSB か

ここは使うボードによって変わります。

  • XIAO ESP32-C3 — ESP32-C3 チップに USB 機能が内蔵されていて、USB-C 端子が直結しています。Linux からは /dev/ttyACM0 に見えます
  • CH340 / CP2102 を積んだ一般的な ESP32 開発ボード — USB-シリアル変換チップが載っているので /dev/ttyUSB0 に見えます

上の serial_test.py は /dev/ttyACM0 決め打ちです。実際に出た名前と違っていたら PORT の行を書き換えてください。

3.4 dialout グループの確認

シリアルポートを開くには dialout グループに入っている必要があります。

groups
groups コマンドで dialout グループへの所属を確認

出力に dialout が含まれていれば OK です。Raspberry Pi OS の既定ユーザーは通常入っています。入っていなければ:

sudo usermod -a -G dialout $USER

この変更はログインし直すまで効きません。 一度 exit して SSH に入り直してください。


4. Master の最小スケッチ — まず MAC を知る

ESP-NOW は「宛先の MAC アドレス」を指定して送る方式です。IP アドレスの代わりに MAC を使う、と考えてください。

なので順序は必ずこうなります。

  1. Master の MAC を調べる
  2. その MAC を子機のスケッチに埋め込む
  3. 子機が Master 宛に送る

まず 1 をやります。Arduino IDE で新しいスケッチを開き、以下を丸ごと貼り付けてください。

4.1 ボード設定

第2回と同じです。ツール メニューで:

  • ボード → XIAO_ESP32C3
  • USB CDC On Boot → Enabled (これが Disabled だとシリアルに何も出ません)
  • Flash Size → 4MB (32Mb)
  • Partition Scheme → Default 4MB with spiffs
  • Upload Speed → 921600 (通らなければ 115200)

4.2 最小 Master スケッチ

/**
 * 第7回 Master 最小版 — ESP-NOW を受信してシリアルに出すだけ
 * ボード: XIAO_ESP32C3 / USB CDC On Boot: Enabled
 */
#include <esp_now.h>
#include <WiFi.h>
#include <esp_wifi.h>

// 子機と完全に一致させる構造体 (1 バイトでも違うと壊れます)
typedef struct {
    char  sensor_id[16];
    char  sensor_name[32];
    float temp;
    float humidity;
    int8_t rssi;
    uint32_t timestamp;
    float battery_voltage;
    bool  battery_mode;
} SensorData;

// ESP-NOW 受信コールバック
// ESP32 Arduino Core 3.x とそれ以前でシグネチャが違うので両対応にしています
#if defined(ESP_ARDUINO_VERSION_MAJOR) && (ESP_ARDUINO_VERSION_MAJOR >= 3)
void onDataRecv(const esp_now_recv_info *info, const uint8_t *data, int len) {
    const uint8_t *mac = info->src_addr;
    int8_t rssi = info->rx_ctrl->rssi;
#else
void onDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
    int8_t rssi = 0;
#endif
    if (len != sizeof(SensorData)) {
        Serial.printf("[NG] サイズ不一致: 受信 %d バイト / 期待 %u バイト\n",
                      len, (unsigned)sizeof(SensorData));
        return;
    }

    SensorData d;
    memcpy(&d, data, sizeof(d));

    Serial.printf("[OK] %02X:%02X:%02X:%02X:%02X:%02X  RSSI=%d  id=%s  温度=%.2f  電池=%.2fV\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
                  rssi, d.sensor_id, d.temp, d.battery_voltage);
}

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

    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    delay(100);

    // 子機と同じチャンネルに固定する (既定でも 1 だが、明示しておくと事故が減る)
    esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);

    uint8_t mac[6];
    WiFi.macAddress(mac);

    Serial.println("\n=== 第7回 Master 最小版 ===");
    Serial.printf("この Master の MAC : %02X:%02X:%02X:%02X:%02X:%02X\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    Serial.printf("子機に貼る C 配列  : {0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X}\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    Serial.printf("構造体サイズ       : %u バイト\n", (unsigned)sizeof(SensorData));

    if (esp_now_init() != ESP_OK) {
        Serial.println("!! ESP-NOW の初期化に失敗しました");
        return;
    }
    esp_now_register_recv_cb(onDataRecv);

    Serial.println("受信待機中...\n");
}

void loop() {
    delay(10);
}

4.3 書き込んで MAC を控える

書き込んだら ツール → シリアルモニタ、ボーレート 115200。以下のように出ます。

=== 第7回 Master 最小版 ===
この Master の MAC : 84:CC:A8:1A:2B:3C
子機に貼る C 配列  : {0x84, 0xCC, 0xA8, 0x1A, 0x2B, 0x3C}
構造体サイズ       : 68 バイト
受信待機中...

「子機に貼る C 配列」の行をコピーしておいてください。 次のステップでそのまま使います。わざわざ C 配列の形で出力しているのは、手で 0x を付けて打ち直すと必ずどこかで間違えるからです。

ESP-NOW 受信役(Master)の MAC アドレスを C 配列形式で出力したシリアルモニタ

「構造体サイズ」も控えておいてください。子機側と一致しているかの確認に使います。

なぜコールバックの書き方が 2 通りあるのか

ESP32 Arduino Core は 3.0.0 で ESP-NOW 受信コールバックの引数が変わりました。旧: (const uint8_t *mac, ...)、新: (const esp_now_recv_info *info, ...)。

新しい方は電波強度 (RSSI) が取れるようになったのが実利です。旧 API では受信側で RSSI が分からず、子機が自己申告した値を使うしかありませんでした。

#if defined(ESP_ARDUINO_VERSION_MAJOR) で分けてあるので、どちらのバージョンでもコンパイルは通ります。


5. 子機の最小スケッチ

子機は第2回で組んだ XIAO ESP32-C3 + DS18B20 をそのまま使います。配線の変更はありません。DS18B20 のデータ線は D2 (GPIO4)、4.7kΩ のプルアップを DATA と 3V3 の間、のままです。

まず DeepSleep なしで動かします。 5 秒ごとに送るだけの版です。

/**
 * 第7回 子機 最小版 — 5 秒ごとに ESP-NOW で送るだけ (DeepSleep なし)
 * ボード: XIAO_ESP32C3 / USB CDC On Boot: Enabled
 */
#include <esp_now.h>
#include <WiFi.h>
#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS 4   // D2

// ★★★ ここを Master のシリアル出力からコピーした値に置き換える ★★★
uint8_t masterMAC[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00};

// Master と完全に一致させる構造体
typedef struct {
    char  sensor_id[16];
    char  sensor_name[32];
    float temp;
    float humidity;
    int8_t rssi;
    uint32_t timestamp;
    float battery_voltage;
    bool  battery_mode;
} SensorData;

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
char sensorId[16];

void onDataSent(const wifi_tx_info_t *info, esp_now_send_status_t status) {
    Serial.printf("[送信結果] %s\n",
                  status == ESP_NOW_SEND_SUCCESS ? "成功" : "失敗");
}

void setup() {
    Serial.begin(115200);
    delay(1000);
    Serial.println("\n=== 第7回 子機 最小版 ===");

    // プレースホルダのまま書き込んだ事故を止める
    bool allZero = true;
    for (int i = 0; i < 6; i++) if (masterMAC[i] != 0x00) allZero = false;
    if (allZero) {
        Serial.println("!! masterMAC が {0x00,...} のままです。");
        Serial.println("!! Master のシリアル出力の値に書き換えてください。停止します。");
        while (true) delay(1000);
    }

    sensors.begin();
    delay(100);
    Serial.printf("DS18B20 検出数: %d\n", sensors.getDeviceCount());

    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    delay(100);

    uint8_t mac[6];
    WiFi.macAddress(mac);
    snprintf(sensorId, sizeof(sensorId), "NOW-%02X%02X%02X",
             mac[3], mac[4], mac[5]);
    Serial.printf("この子機の MAC : %02X:%02X:%02X:%02X:%02X:%02X\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    Serial.printf("sensor_id      : %s\n", sensorId);
    Serial.printf("構造体サイズ   : %u バイト\n", (unsigned)sizeof(SensorData));

    if (esp_now_init() != ESP_OK) {
        Serial.println("!! ESP-NOW の初期化に失敗しました");
        return;
    }
    esp_now_register_send_cb(onDataSent);

    esp_now_peer_info_t peer = {};
    memcpy(peer.peer_addr, masterMAC, 6);
    peer.channel = 1;
    peer.encrypt = false;
    if (esp_now_add_peer(&peer) != ESP_OK) {
        Serial.println("!! ピア登録に失敗しました");
        return;
    }
    Serial.println("準備完了\n");
}

void loop() {
    sensors.requestTemperatures();
    float temp = sensors.getTempCByIndex(0);

    SensorData d = {};
    strncpy(d.sensor_id, sensorId, sizeof(d.sensor_id) - 1);
    strncpy(d.sensor_name, "DS18B20-NOW", sizeof(d.sensor_name) - 1);
    d.temp = temp;
    d.humidity = 0.0;
    d.rssi = 0;
    d.timestamp = millis();
    d.battery_voltage = 0.0;   // 最小版では測らない
    d.battery_mode = false;

    Serial.printf("温度=%.2f を送信します\n", temp);
    esp_now_send(masterMAC, (uint8_t *)&d, sizeof(d));

    delay(5000);
}

5.1 masterMAC の書き換え

uint8_t masterMAC[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; の行を、Master のシリアルモニタに出た C 配列で置き換えます。

uint8_t masterMAC[] = {0x84, 0xCC, 0xA8, 0x1A, 0x2B, 0x3C};

書き換え忘れの事故を何度もやったので、ゼロのままだと起動時に止まるガードを入れてあります。シリアルに「masterMAC が {0x00,…} のままです」と出たら、ここを直し忘れています。

5.2 動かす

ESP-NOW の Master と電池駆動の子機をそれぞれブレッドボードに組んだところ

Master と子機の両方に電源を入れます。Master 側のシリアルモニタに 5 秒おきにこう出れば成功です。

[OK] 84:CC:A8:D4:E5:F6  RSSI=-31  id=NOW-D4E5F6  温度=26.75  電池=0.00V
[OK] 84:CC:A8:D4:E5:F6  RSSI=-32  id=NOW-D4E5F6  温度=26.75  電池=0.00V

子機側のシリアルモニタには [送信結果] 成功 が出ます。

子機のシリアルモニタに出た ESP-NOW 送信結果「成功」

Arduino IDE のシリアルモニタは 1 つしか開けません。 2 台同時に見たい場合は、Arduino IDE をもう 1 つ起動するか、片方を Pi 側の serial_test.py で見てください。

Master のシリアルモニタに出た子機からの受信データ

5.3 Pi につないでみる

Master の USB を PC から抜き、Pi の USB ポートに挿します。

ls /dev/ttyACM* /dev/ttyUSB*

/dev/ttyACM0 が現れるはずです。現れた名前で serial_test.py を実行:

ls /dev/ttyACM* で Master の USB シリアルを確認
python3 ~/serial_test.py
/dev/ttyACM0 を開きました。待機中... (Ctrl+C で終了)
受信: === 第7回 Master 最小版 ===
受信: この Master の MAC : 84:CC:A8:1A:2B:3C
受信: 受信待機中...
受信: [OK] 84:CC:A8:D4:E5:F6  RSSI=-31  id=NOW-D4E5F6  温度=26.75  電池=0.00V

ここまで来れば、無線の経路とシリアルの経路が両方つながったことになります。

Raspberry Pi 上の serial_test.py で Master の出力を受信

動いたので、中身の説明をします。

6.1 ESP-NOW は「WiFi の下の層」を直接使う

普通の WiFi 通信はこういう層になっています。

アプリ (HTTP POST)
  ↓
TCP / IP        ← IP アドレス、ポート番号
  ↓
WiFi の認証・接続 ← SSID、パスワード、DHCP
  ↓
電波 (802.11)

上から順に確立していく必要があるので時間がかかります。第6回で 2〜3 秒かかっていたのは、真ん中の 2 層の手続きです。

ESP-NOW はここを飛ばします。

アプリ (構造体をそのまま送る)
  ↓
電波 (802.11 のフレームに直接載せる)

IP もポートも TCP もありません。宛先 MAC アドレスを指定して、バイト列をそのまま電波に載せるだけです。だから速い。

代償として、以下が使えません。

  • IP アドレスによる指定 (MAC を知っている相手にしか送れない)
  • ルーター越え (同じ電波の届く範囲だけ)
  • Pi のような普通のパソコンとの直接通信 (ESP-NOW を喋れないため)

3 つ目が、受信役の ESP を立てる理由です。

6.2 構造体をそのまま送るということ

esp_now_send(masterMAC, (uint8_t *)&d, sizeof(d)); の行が送信です。構造体 d のメモリ上のバイト列を、そのまま送っています。

これが速さの正体であると同時に、最大の落とし穴でもあります。

受信側は「送られてきたバイト列を、自分の構造体の形に当てはめる」という解釈をします。送信側と受信側で構造体の定義が 1 バイトでも違うと、値がずれます。 JSON のようにフィールド名で対応を取ってくれる仕組みはありません。

だから最小版のスケッチでは:

  • 両方に「構造体サイズ」をシリアル出力させて、目視で一致を確認できるように
  • 受信側で if (len != sizeof(SensorData)) を見て、違っていたら弾く

ようにしてあります。この 2 つは、後で構造体にフィールドを足したくなったときに効いてきます。片方だけ書き換えて片方を書き忘れるのが定番の事故で、これがあれば起動直後に気づけます。

6.3 送信結果「成功」の意味に注意

[送信結果] 成功 は 「電波を出して、相手から ACK が返ってきた」という意味です。相手が中身を正しく解釈できたかは含みません。

構造体がずれていても、相手のスケッチが固まっていても、電波が届いていれば「成功」と出ます。送信側だけ見て安心しないでください。 受信側のログを見るのが確認です。

逆に「失敗」が出るときは、たいてい次のどれかです。

  • 宛先 MAC が違う (相手が存在しない)
  • 相手の電源が入っていない
  • チャンネルが合っていない
  • 距離が遠すぎる / 遮蔽物

7. 本番版へ — 何を足すか

最小版で経路が確認できたので、実運用に必要なものを足します。

足すもの理由
DeepSleepこれがないと電池運用にならない。ESP-NOW を使う目的そのもの
電池電圧の測定現地で電池切れを検知するため。第6回の ESP8266 版と同じ発想
Master 側の JSON 出力第8回で Flask に食わせる。人間向けログのままでは機械が読めない
Master の自己申告Master 自身もダッシュボードに出したい。起動時と定期の 2 種類
DS18B20 のリトライ第6回の -127 騒動の教訓。1 回失敗しただけで欠測にしない

7.1 ESP32-C3 の DeepSleep は配線不要

第6回で ESP8266 を DeepSleep させたとき、GPIO16 と RST をジャンパで結ぶ必要がありました。ESP8266 は内部のタイマーが RST ピンを外から叩く構造だったからです。

ESP32-C3 はこの配線が要りません。 RTC タイマーがチップ内部で完結しているので、esp_sleep_enable_timer_wakeup() と esp_deep_sleep_start() を呼ぶだけで起きてきます。第6回でジャンパの接触不良に悩んだ方は、ここは素直に喜んでいいところです。

ただし DeepSleep には別の面倒が付いてきます。寝ている間は USB のポートごと消えるので、次の書き込みができなくなります。対処は 8.2 の直前にまとめました。本番版を書き込む前に必ず読んでください。

7.2 電池電圧の測り方 (子機)

実測で確認しました。 LiPo を繋いだ子機で、テスターの実測値とダッシュボードの表示を突き合わせた結果が下です。

テスターで測った電池電圧4.10 V
ダッシュボードの表示4.04 V
差−0.06 V (1.5%)

この用途には十分です。 LiPo の実用範囲は 4.2〜3.0 V の 1.2 V 幅なので、0.06 V のずれは残量にして 5% ほど。「そろそろ切れる」を知るには余裕があります。誤差の出どころは抵抗の許容差、ADC の校正、テスター自体の確度、測定タイミングの差 (LiPo は負荷で振れます) の合算です。

校正係数を掛けるのは勧めません。 1 点だけで合わせると、電圧が下がったときにかえってずれます。やるなら満充電と放電後の 2 点を測ってからです。残量の目安として使う分には、素の値で足ります。

XIAO ESP32-C3 に LiPo バッテリー(3.7Vただし充電直後は4.2V位)を付ける場合、電池電圧は分圧して ADC で読みます。今回の記事では実装しません。

BAT+ ──[270kΩ]──┬──[270kΩ]── GND
                 │
                 A1 (GPIO3)

1:1 の分圧なので、読んだ値を 2 倍すれば電池電圧です。上のとおり実測で 1.5% 以内に収まっています。

A0 (GPIO2) を使ってはいけません。 GPIO2 は ESP32-C3 のストラッピングピンで、起動時の状態を見られます。分圧回路をつなぐと書き込みモードに入れなくなることがあります。A1 (GPIO3) を使ってください。(AIはこの辺を見落とすことが多い)

しきい値は 3.0V にしています。LiPo の一般的な放電下限です。この値を下回ると battery_mode に 1 が入り、ダッシュボードに (残量低下) と出ます。第6回の ESP8266 版と意味を揃えてあります — どちらも「電池で動いているか」ではなく「残量が少ないか」です。ただししきい値そのものは電池の種類で違い (単三 3 本なら 3.3V)、どちらも使い切ったときのデータを取っていないので、実際にどう出るかは分かりません。

コード側は analogReadMilliVolts() を使います。analogRead() の生の値を自分で電圧に換算するより正確です。ESP32 は工場出荷時に ADC のキャリブレーション値が焼き込まれていて、この関数がそれを使ってくれます。16 回読んで平均を取っているのはノイズ対策です。

7.3 Master は寝かせない

Master は USB で Pi から給電されるので、電池の心配がありません。常時起きたままにします。子機がいつ送ってくるか分からないので、Master が寝ていたら受け損ねます。


8. 本番スケッチ全文

8.1 Master (XIAO ESP32-C3、USB で Pi に接続)

ここから先は実際に現場で動いているファイルをそのまま載せます。記事のために書き直したものではありません。ヘッダーの修正履歴に、いつ何を直したかがそのまま残っています。

確認ポイント: このファイルは ESP32 (無印) での稼働実績があるものです。XIAO ESP32-C3 に書き込む場合は、最初の 1 回だけシリアルモニタを開いて [ESP-NOW] Received from ... が出ることを確認してください。ボード設定は XIAO_ESP32C3 / USB CDC On Boot: Enabled です。

/**
 * ============================================================
 *  ESP32 Master (ESP-NOW 受信 + Serial 出力)
 *
 *  役割: 各 ESP-NOW センサーからのデータを受信し、
 *        Serial (USB CDC) 経由で Raspberry Pi に JSON で転送する
 *
 *  ラズパイ側は services/serial_reader.py が Serial 出力を読取り、
 *  /api/temperature に転送する (SERIAL_ENABLED=True 必須)
 *
 *  修正履歴:
 *    2026-01     I: 側 初版 (稼働実績あり)
 *    2026-07-30  Z: 統合、以下 2 点を修正:
 *      - ESP32 Arduino Core v2.x/v3.x 両対応の ESP-NOW 受信コールバック
 *      - WiFi.macAddress()[0] の表示バグ修正 (String indexing → uint8_t 配列)
 *    2026-08-15  3 層分離アーキテクチャに整合:
 *      - MASTER_DEVICE_ID ハードコード撤廃、MAC 由来 "MST-XXXXXX" に自動生成
 *      - 起動時 master_hello、60 秒ごと master_status を Serial に送出
 *        → Pi 側 serial_reader が受け取り device_nicknames に自己登録、
 *           ダッシュボード管理画面に自動で現れる。全 Master 共通スケッチ化。
 *    2026-09-05  SensorData に battery_voltage / battery_mode を追加 (見落としの修正):
 *      - 子機 (ESP32C3_ESPNOW_Battery_Sensor) の構造体には元から有ったが
 *        Master 側に無く、memcpy で切り詰められて電池電圧が捨てられていた
 *      - ESP32_ESPNOW_Sensor (常時給電) も同じ構造体に揃えた (0.0 / false 固定)
 *      - 受信時に len != sizeof(SensorData) を弾くチェックを追加
 *      - Pi へ転送する JSON に voltage / battery_mode を追加
 *      ★3 スケッチの SensorData は常に同一に保つこと
 * ============================================================
 */

#include <esp_now.h>
#include <WiFi.h>
#include <ArduinoJson.h>

#define BAUD_RATE 115200
#define STATUS_INTERVAL_MS 60000UL   // 60 秒ごとに master_status 送出

// ESP-NOW で受信するセンサーデータ構造 (子機側と一致必須、変更禁止)
typedef struct {
    char sensor_id[16];      // "NOW-A1B2C3" など
    char sensor_name[32];    // "DS18B20-01" など
    float temp;              // 温度 (℃)
    float humidity;          // 湿度 (%)
    int8_t rssi;             // 信号強度 (dBm)
    uint32_t timestamp;      // タイムスタンプ
    float battery_voltage;   // 電池電圧 (V)。常時給電機は 0.0
    bool  battery_mode;      // 電池低下なら true。常時給電機は false
} SensorData;

// グローバル
SensorData received_data[10];
int data_count = 0;
unsigned long last_send_time = 0;
unsigned long last_status_time = 0;

// MAC 由来の Master ID (setup で生成)
char master_id[16];        // "MST-A1B2C3"
char master_mac[18];       // "2C:BC:BB:4D:99:BC"

// ============================================================
//  ESP-NOW 受信コールバック
//  ESP32 Arduino Core v3.0.0 で API シグネチャが変わったので両対応
// ============================================================
#if defined(ESP_ARDUINO_VERSION_MAJOR) && (ESP_ARDUINO_VERSION_MAJOR >= 3)
void on_data_recv(const esp_now_recv_info *recv_info, const uint8_t *incomingData, int len) {
    const uint8_t *src_mac = recv_info->src_addr;
    int8_t packet_rssi = recv_info->rx_ctrl->rssi;
#else
void on_data_recv(const uint8_t *src_mac, const uint8_t *incomingData, int len) {
    int8_t packet_rssi = 0;
#endif
    // 構造体が子機と一致しているか確認する。
    // 一致しないまま memcpy すると境界外を読むので、ここで弾く。
    if (len != (int)sizeof(SensorData)) {
        Serial.printf("[ESP-NOW] !! struct size mismatch: received=%d expected=%u  "
                      "(子機と Master の SensorData を揃えてください)\n",
                      len, (unsigned)sizeof(SensorData));
        return;
    }
    if (data_count < 10) {
        memcpy(&received_data[data_count], incomingData, sizeof(SensorData));
        if (packet_rssi != 0) {
            received_data[data_count].rssi = packet_rssi;
        }
        data_count++;
        Serial.printf("[ESP-NOW] Received from %02X:%02X:%02X:%02X:%02X:%02X RSSI=%d\n",
            src_mac[0], src_mac[1], src_mac[2],
            src_mac[3], src_mac[4], src_mac[5],
            packet_rssi);
    }
}

// ============================================================
//  Pi 側に「自分は誰か」を伝える 2 種類の JSON
//  serial_reader.py 側で type フィールドで判別する
// ============================================================

// 起動時 1 回だけ (Pi が既に動いていた場合の登録用)
void send_master_hello() {
    StaticJsonDocument<256> doc;
    doc["type"] = "master_hello";
    doc["device_id"] = master_id;
    doc["mac"] = master_mac;
    doc["firmware"] = "ESP32_ESPNOW_Master 2026-09-05";
    doc["struct_size"] = (int)sizeof(SensorData);
    serializeJson(doc, Serial);
    Serial.println();
}

// 60 秒ごと (Pi が後から起動した場合の登録用 + heartbeat)
void send_master_status() {
    StaticJsonDocument<256> doc;
    doc["type"] = "master_status";
    doc["device_id"] = master_id;
    doc["mac"] = master_mac;
    doc["uptime_ms"] = millis();
    serializeJson(doc, Serial);
    Serial.println();
}

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

    // WiFi 初期化 (ESP-NOW に必須、MAC 読取りにも必要)
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    delay(100);

    // MAC アドレス取得 → master_id と表示用 mac 文字列を生成
    uint8_t mac[6] = {0};
    WiFi.macAddress(mac);
    snprintf(master_id, sizeof(master_id), "MST-%02X%02X%02X",
             mac[3], mac[4], mac[5]);
    snprintf(master_mac, sizeof(master_mac), "%02X:%02X:%02X:%02X:%02X:%02X",
             mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);

    Serial.println("\n\n=== ESP32 Master (ESP-NOW, MAC-based ID) ===");
    Serial.printf("MAC        : %s\n", master_mac);
    Serial.printf("device_id  : %s\n", master_id);

    // ESP-NOW 初期化
    if (esp_now_init() != ESP_OK) {
        Serial.println("ERROR: ESP-NOW initialization failed");
        return;
    }
    esp_now_register_recv_cb(on_data_recv);

    Serial.println("Status: Ready to receive ESP-NOW data");

    // 起動時に一度だけ master_hello を送出 (Pi が動作中なら即登録される)
    send_master_hello();
    last_status_time = millis();

    Serial.println("Waiting for sensor data...\n");
}

void loop() {
    unsigned long current_time = millis();

    // 1 秒ごとにセンサーデータを Pi に転送
    if (current_time - last_send_time >= 1000) {
        if (data_count > 0) {
            send_to_raspberry();
            data_count = 0;
        }
        last_send_time = current_time;
    }

    // 60 秒ごとに heartbeat (Pi が後から起動しても検知できるように)
    if (current_time - last_status_time >= STATUS_INTERVAL_MS) {
        send_master_status();
        last_status_time = current_time;
    }

    delay(10);
}

void send_to_raspberry() {
    StaticJsonDocument<512> doc;
    doc["device_id"] = master_id;      // MAC 由来 (旧: "ESP32_MAIN")
    doc["timestamp"] = millis();

    JsonArray sensors = doc.createNestedArray("sensors");
    for (int i = 0; i < data_count; i++) {
        JsonObject sensor = sensors.createNestedObject();
        sensor["sensor_id"] = received_data[i].sensor_id;
        sensor["sensor_name"] = received_data[i].sensor_name;
        sensor["temperature"] = received_data[i].temp;
        sensor["humidity"] = received_data[i].humidity;
        sensor["rssi"] = received_data[i].rssi;
        sensor["voltage"] = received_data[i].battery_voltage;
        sensor["battery_mode"] = received_data[i].battery_mode ? 1 : 0;
    }

    serializeJson(doc, Serial);
    Serial.println();
}

Master が Pi に流すもの

シリアルには 3 種類の行が出ます。第8回で Flask に食わせるとき、この形を前提にします。

{"type":"master_hello","device_id":"MST-1A2B3C","mac":"84:CC:A8:1A:2B:3C","firmware":"ESP32_ESPNOW_Master 2026-09-05","struct_size":72}
[ESP-NOW] Received from 84:CC:A8:D4:E5:F6 RSSI=-31
{"device_id":"MST-1A2B3C","timestamp":48210,"sensors":[{"sensor_id":"NOW-D4E5F6","sensor_name":"DS18B20-NOW-Bat","temperature":26.81,"humidity":0,"rssi":-31,"voltage":3.92,"battery_mode":0}]}

注: 上の例の "voltage":3.92 は書式を示すためのサンプル値です。実機では 7.2 のとおり LiPo で 4.04 V を読めています。分圧回路を組んでいない子機や常時給電の子機では 0.0 が入ります。

行中身
{"type":"master_hello",...}起動時に 1 回。Master 自身の名乗り。struct_size は子機と突き合わせる用。実機は 72 (詰め物を含んだ sizeof(SensorData))
{"type":"master_status",...}60 秒ごと。生存確認 (heartbeat)。Pi を後から起動しても Master を見つけられる
[ESP-NOW] Received from ...人間が見るための行。JSON ではないので Pi 側は読み飛ばす
{"device_id":...,"sensors":[...]}温度データ。1 秒ごとに、その間に受けた分をまとめて配列で出す

ここは間違えやすいので強調しておきます。 温度データの行にある device_id は Master 自身の ID です。子機の ID は sensors[].sensor_id のほうです。素直に device_id で保存すると、すべての温度が Master 名義で記録されます。

1 秒まとめて出すのは、子機が同時に何台か送ってきたときに Pi 側の処理を減らすためです。子機は 5 分に 1 回しか起きないので、実際に 1 つの配列に 2 件以上入るのは稀ですが、台数が増えたときに効いてきます。

⚠️ 書き込む前に必ず読んでください — DeepSleep 中は書き込めません

XIAO ESP32-C3 は DeepSleep に入ると、PC のデバイスマネージャーからもポートごと消えます。ポートが無いので、そのままでは次のスケッチを書き込めません。

第1回の ESP-WROOM-02 (生チップ) は、そもそも毎回手動で書き込みモードに入れる運用でした。だからこの問題は起きません。一方 XIAO ESP32-C3 は開発ボードに近い扱いで、普段は自動で書き込みモードに入ってくれます。だからこそ「いつも通り書けるはず」と思い込んで、ここでつまずきます。

これから書き込む本番版スケッチは、起動して数秒で DeepSleep に入ります。一度書き込んだら、次からは手動で BOOT モードに入れる必要があります。

手動で BOOT モードに入れる手順 (本体のボタンを使います)

  1. BOOT ボタンを押したままにする
  2. BOOT を押したまま、RESET ボタンを押す
  3. 先に RESET ボタンを離す
  4. そのあと BOOT ボタンを離す

この順番を守らないと BOOT モードに入りません。3 と 4 の順序が逆になりやすいので注意してください。

BOOT モードに入ると、Windows のデバイスマネージャーや ls /dev/ttyACM* (Linux) にポートが現れます。Arduino IDE の ツール → シリアルポート でそのポートを選び直してから書き込んでください。

DeepSleep の周期を長くするほど、起きている一瞬を狙って書き込むのは難しくなります。SEND_INTERVAL を 300 秒 (5 分) や 1800 秒 (30 分) にしたあとは、BOOT モードでの書き込みが事実上の唯一の方法だと考えてください。

8.2 子機 (XIAO ESP32-C3 + DS18B20、電池駆動)

/**
 * ============================================================
 *  XIAO ESP32C3 Battery-powered Sensor (ESP-NOW 送信版)
 *
 *  ベース: I:\...\esp_now_master_and_sensor\ESP32C3_Battery_Sensor\
 *          ESP32C3_Battery_Sensor_ESPNOW_Production\ (稼働実績あり、現地展開済)
 *
 *  修正 (2026-07-30):
 *    - sensor_id を MAC 由来の "NOW-XXXXXX" 形式に自動生成
 *      → WiFi 直接 POST 系 (ESP-XXXXXX) と区別できる命名
 *      → per-chip 書換え不要
 *    - SENSOR_ID ハードコード ("NOW_BAT_02") 廃止
 *    - SENSOR_NAME は "DS18B20-NOW-Bat" 汎用名に (nickname テーブルで個別化)
 *
 *  【変更しなかった項目 (稼働実績を尊重)】:
 *    - DS18B20 = GPIO4 (D2)   XIAO ESP32-C3 は GPIO0 が外部未引出しのため GPIO4 を採用
 *                              (2026-08-16 検討: WiFi 版と統一、無印 ESP32 でも共通利用可)
 *    - バッテリー ADC = A1 (GPIO3、270kΩ 1:1 分圧、LiPo 3.7V)
 *    - analogReadMilliVolts + 16 サンプル平均
 *    - LiPo 低下閾値 3.0V
 *    - SEND_INTERVAL 300 秒、DeepSleep
 *    - DEBUG_MODE フラグ
 * ============================================================
 */

#include <esp_now.h>
#include <WiFi.h>
#include <OneWire.h>
#include <DallasTemperature.h>

// ===== 設定 =====
#define ONE_WIRE_BUS 4        // DS18B20 データピン (GPIO4 = D2、XIAO/無印 ESP32 共通で使える)
#define BATTERY_ADC_PIN A1    // バッテリー監視 (A1/GPIO3に変更 - GPIO2/A0はストラッピングピンのため回避)
#define SENSOR_NAME "DS18B20-NOW-Bat"  // nickname 未設定時のフォールバック
// ------------------------------------------------------------
// 【測定周期の変更方法】 単位: 秒。値を書換えるだけ (loop 内で ×1e6 して μs に変換)
//   30 秒周期 → 30 / 60 秒 → 60 / 5 分 → 300 (現行) / 10 分 → 600 / 30 分 → 1800 / 1 時間 → 3600
//   周期を長くすると電池寿命が伸びる。ESP-NOW は通信 ~0.2 秒 + 温度取得 ~1 秒 なので、
//   短周期 (30 秒) でも WiFi 版よりずっと電池持ちが良い。
// ------------------------------------------------------------
#define SEND_INTERVAL 300      // 300秒 = 5 分ごとに送信 (電池駆動、秒単位)
#define DEBUG_MODE false       // デバッグモード(Deep Sleepを無効化してテスト)

// マスター ESP32 の MAC アドレス (Master 起動時の Serial モニタで確認、書換え可)
// ★★★ Master のシリアル出力に出た C 配列をここに貼る ★★★
uint8_t masterMAC[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00};

// MAC 由来 sensor_id (setup 内で生成、"NOW-XXXXXX" 形式)
char sensor_id[16];

// センサーデータ構造
typedef struct {
    char sensor_id[16];
    char sensor_name[32];
    float temp;
    float humidity;
    int8_t rssi;
    uint32_t timestamp;
    float battery_voltage;
    bool battery_mode;
} SensorData;

// OneWire と DallasTemperature インスタンス
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

// ===== バッテリー検出関数 =====
// XIAO ESP32C3用 - 分圧回路対応(BAT+ -> R1(270k) -> A1 -> R2(270k) -> GND)
// 注意: A0(GPIO2)はストラッピングピンのため使用不可
// A1(GPIO3)を使用することで、プログラム書き込み時の問題を回避
float getBatteryVoltage() {
    // 自動キャリブレーション対応(analogReadMilliVolts使用)
    uint32_t sumVoltage = 0;
    
    // 16回読み込んで平均化(ノイズ除去)
    for (int i = 0; i < 16; i++) {
        // A1(GPIO3)を使用 - GPIO2/A0はストラッピングピンのため回避
        sumVoltage += analogReadMilliVolts(BATTERY_ADC_PIN);
        delayMicroseconds(50);
    }
    
    // 平均値を計算
    float averageMilliV = sumVoltage / 16.0;
    
    // 分圧回路(1:1分圧)の補正:2倍にする
    float batteryVoltage = (averageMilliV / 1000.0) * 2.0;
    
    return batteryVoltage;
}

// バッテリーモード判定(低下検出)
bool isBatteryLow(float voltage) {
    // 18650: 3.0V未満で低下と判定
    return voltage < 3.0;
}

// ESP-NOW送信コールバック
void on_data_sent(const wifi_tx_info_t *info, esp_now_send_status_t status) {
    Serial.print("[CALLBACK] ESP-NOW Send Status: ");
    if (status == ESP_NOW_SEND_SUCCESS) {
        Serial.println("SUCCESS");
    } else {
        Serial.print("FAILED (status code: ");
        Serial.print(status);
        Serial.println(")");
    }
}

void setup() {
    // シリアル初期化(即座に開始)
    Serial.begin(115200);
    
    // 最初の出力を即座に試みる(デバッグ用)
    Serial.println("\n\n");
    Serial.println("=== STARTUP ===");
    delay(500);  // シリアルポートが確立されるまで待機
    
    // Deep Sleep復帰理由を表示
    esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause();
    Serial.print("Wakeup reason: ");
    switch(wakeup_reason) {
        case ESP_SLEEP_WAKEUP_EXT0:
            Serial.println("External signal using RTC_IO");
            break;
        case ESP_SLEEP_WAKEUP_EXT1:
            Serial.println("External signal using RTC_CNTL");
            break;
        case ESP_SLEEP_WAKEUP_TIMER:
            Serial.println("Timer");
            break;
        case ESP_SLEEP_WAKEUP_TOUCHPAD:
            Serial.println("Touchpad");
            break;
        case ESP_SLEEP_WAKEUP_ULP:
            Serial.println("ULP program");
            break;
        default:
            Serial.println("Power on reset or first boot");
            break;
    }
    
    Serial.println("\n=== XIAO ESP32C3 Sensor with Battery (DS18B20) - ESP-NOW ===");
    Serial.println("sensor_id (MAC-based): " + String(sensor_id));
    Serial.println("Sensor Name (fallback): " + String(SENSOR_NAME));
    Serial.println("Mode: Battery Powered (1min interval)");
    Serial.println("Communication: ESP-NOW");
    Serial.println("DS18B20 Pin: GPIO" + String(ONE_WIRE_BUS));
    Serial.println("Battery ADC Pin: A1 (GPIO3)");
    Serial.print("DEBUG_MODE: ");
    Serial.println(DEBUG_MODE ? "ON (Deep Sleep disabled)" : "OFF");
    
    // DS18B20初期化(WiFi初期化の前に行う - 動作確認済みの方法)
    Serial.println("\n--- DS18B20 Detection ---");
    sensors.begin();
    delay(100);  // 初期化待機(動作確認済みコードと同じ)
    
    int deviceCount = sensors.getDeviceCount();
    Serial.print("DS18B20 Found: ");
    Serial.println(deviceCount);
    
    if (deviceCount == 0) {
        Serial.println("ERROR: No DS18B20 sensor found!");
        Serial.println("Check wiring and power supply");
        Serial.println("Wiring:");
        Serial.println("  - DS18B20 DATA → GPIO4 (D2)");
        Serial.println("  - DS18B20 VCC → 3.3V");
        Serial.println("  - DS18B20 GND → GND");
        Serial.println("  - 4.7kΩ pullup resistor between DATA and VCC");
        delay(5000);
        if (!DEBUG_MODE) {
            goToDeepSleep();
        } else {
            Serial.println("DEBUG_MODE: Continuing despite error...");
        }
    } else {
        Serial.println("✅ DS18B20 sensor detected successfully!");
    }
    
    // バッテリー電圧表示
    float battVoltage = getBatteryVoltage();
    bool battLow = isBatteryLow(battVoltage);
    Serial.printf("Battery Voltage: %.2fV\n", battVoltage);
    Serial.printf("Battery Status: %s\n", battLow ? "⚠️ LOW" : "🔋 GOOD");
    
    Serial.flush();  // シリアル出力を確実に送信
    
    // WiFi初期化(ESP-NOWに必須、MAC 読取りにも必要)
    Serial.println("\n--- WiFi/ESP-NOW Initialization ---");
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    delay(100);

    // MAC 由来 sensor_id を生成 ("NOW-XXXXXX")
    uint8_t mac[6] = {0};
    WiFi.macAddress(mac);
    snprintf(sensor_id, sizeof(sensor_id), "NOW-%02X%02X%02X",
             mac[3], mac[4], mac[5]);
    Serial.printf("ESP32C3 MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    Serial.printf("sensor_id auto-generated: %s\n", sensor_id);
    
    // ESP-NOW初期化
    Serial.println("Initializing ESP-NOW...");
    esp_err_t init_result = esp_now_init();
    if (init_result != ESP_OK) {
        Serial.print("ERROR: ESP-NOW initialization failed: ");
        Serial.println(init_result);
        Serial.println("Retrying in 2 seconds...");
        delay(2000);
        // 再試行
        init_result = esp_now_init();
        if (init_result != ESP_OK) {
            Serial.println("ERROR: ESP-NOW initialization failed again!");
            Serial.println("Will continue anyway for debugging...");
        } else {
            Serial.println("ESP-NOW initialized successfully on retry");
        }
    } else {
        Serial.println("ESP-NOW initialized successfully");
    }
    
    // コールバック登録
    Serial.println("Registering send callback...");
    esp_now_register_send_cb(on_data_sent);
    
    // ピア登録(マスター ESP32)
    Serial.print("Adding peer (MAC: ");
    for(int i = 0; i < 6; i++) {
        if(i > 0) Serial.print(":");
        Serial.print(masterMAC[i], HEX);
    }
    Serial.println(")...");
    
    esp_now_peer_info_t peerInfo = {};
    memcpy(peerInfo.peer_addr, masterMAC, 6);
    peerInfo.channel = 1;
    peerInfo.encrypt = false;
    esp_err_t peer_result = esp_now_add_peer(&peerInfo);
    if (peer_result != ESP_OK) {
        Serial.print("ERROR: Failed to add peer: ");
        Serial.println(peer_result);
        Serial.println("Will continue anyway for debugging...");
    } else {
        Serial.println("Master ESP32 registered successfully");
    }
    
    Serial.println("Status: Ready to send sensor data\n");
}

void loop() {
    Serial.println("\n--- Loop Start ---");
    Serial.flush();  // シリアル出力を確実に送信
    
    // DS18B20 から温度を読み込み(動作確認済みコードと同じ方法)
    // WiFi初期化後でも動作するように、sensors.begin()を再実行
    sensors.begin();
    delay(50);
    
    sensors.requestTemperatures();
    delay(100);  // 変換完了待機(動作確認済みコードと同じ)
    
    float temp = sensors.getTempCByIndex(0);
    
    // エラーチェック
    if (temp == DEVICE_DISCONNECTED_C) {
        Serial.println("ERROR: DS18B20 disconnected or not responding");
        Serial.print("  Temperature value: ");
        Serial.println(temp, 2);
        
        // 再初期化を試みる
        Serial.println("Attempting to reinitialize DS18B20...");
        sensors.begin();
        delay(200);
        sensors.requestTemperatures();
        delay(200);
        temp = sensors.getTempCByIndex(0);
        
        if (temp == DEVICE_DISCONNECTED_C) {
            Serial.println("ERROR: Still not responding after reinit");
            if (!DEBUG_MODE) {
                goToDeepSleep();
            } else {
                Serial.println("DEBUG_MODE: Waiting 5 seconds before retry...");
                delay(5000);
            }
            return;
        } else {
            Serial.println("✅ Reinitialization successful!");
        }
    }
    
    Serial.printf("Temperature: %.2f°C\n", temp);
    
    // ESP-NOWで送信
    Serial.println("Sending data via ESP-NOW...");
    sendDataViaESPNOW(temp);
    
    // 送信完了を待つ(デバッグ用)
    delay(500);
    
    // Deep Sleep へ(60秒)
    if (DEBUG_MODE) {
        Serial.println("DEBUG_MODE: Deep Sleep disabled, waiting 10 seconds...");
        delay(10000);
    } else {
        goToDeepSleep();
    }
}

// ESP-NOW経由で送信(低電力、WiFi接続不要)
void sendDataViaESPNOW(float temperature) {
    Serial.println("Preparing sensor data...");
    
    // センサーデータ構造体を作成
    SensorData sensor_data;
    strncpy(sensor_data.sensor_id, sensor_id, sizeof(sensor_data.sensor_id) - 1);
    sensor_data.sensor_id[sizeof(sensor_data.sensor_id) - 1] = '\0';
    strncpy(sensor_data.sensor_name, SENSOR_NAME, sizeof(sensor_data.sensor_name) - 1);
    sensor_data.sensor_name[sizeof(sensor_data.sensor_name) - 1] = '\0';
    sensor_data.temp = temperature;
    sensor_data.humidity = 0.0;  // DS18B20 は湿度を測定しないので 0
    sensor_data.rssi = WiFi.RSSI();
    sensor_data.timestamp = millis();
    sensor_data.battery_voltage = getBatteryVoltage();
    sensor_data.battery_mode = isBatteryLow(sensor_data.battery_voltage);
    
    Serial.printf("Data prepared - Temp: %.2f°C, Battery: %.2fV\n", 
                  sensor_data.temp, sensor_data.battery_voltage);
    Serial.printf("Data size: %d bytes\n", sizeof(sensor_data));
    
    // 送信
    Serial.println("Calling esp_now_send()...");
    esp_err_t result = esp_now_send(masterMAC, (uint8_t *)&sensor_data, sizeof(sensor_data));
    
    if (result == ESP_OK) {
        Serial.println("esp_now_send() returned ESP_OK (callback will show actual status)");
    } else {
        Serial.print("ERROR: esp_now_send() failed with code: ");
        Serial.println(result);
    }
}

// Deep Sleep へ移行(60秒間スリープ)
void goToDeepSleep() {
    Serial.printf("Going to Deep Sleep for %d seconds...\n", SEND_INTERVAL);
    Serial.flush();
    
    // 60秒 = 60,000,000マイクロ秒
    esp_sleep_enable_timer_wakeup(SEND_INTERVAL * 1000000);
    
    // Deep Sleep に入る
    esp_deep_sleep_start();
}

書き込む前に変える 3 か所

行内容
uint8_t masterMAC[]必須。 Master のシリアルに出た MAC に置き換える。{0x00, ...} のままでは届かない
#define SEND_INTERVAL 300送信周期 (秒)。動作確認中は 30 にすると待ち時間が減る
#define DEBUG_MODE falsetrue にすると DeepSleep せず 10 秒ごとに繰り返す。動作確認が終わったら必ず false に戻す

DEBUG_MODE を true にしたまま電池運用に入れないでください。 寝ないので数時間で電池が空になります。

このスケッチには DS18B20 disconnected のときに 1 回だけ読み直す処理が入っています (loop() 内)。第6回で扱った -127 が一過性で出ることがあるためで、これで拾えるケースがあります。

sensor_id は MAC から NOW-XXXXXX の形で自動生成されます。WiFi 直接 POST 系の ESP-XXXXXX と接頭辞が違うので、ダッシュボードでどちらの経路で来たデバイスか一目で分かります。チップを何台足してもスケッチの書き換えは masterMAC の 1 行だけです。

loop() が空なのはなぜか: DeepSleep から復帰すると、ESP は loop() の続きからではなく setup() の先頭からやり直します。電源を入れ直したのと同じ扱いです。だから処理は全部 setup() に書いて、最後に寝ます。第6回の ESP8266 版と同じ考え方です。

子機がほぼ 5 分間隔で送信に成功しているシリアルモニタのログ

ほぼ5分間隔で送信し、成功しています。

9. よくある失敗

症状原因対処
Master に何も出ない。子機は「送信結果 成功」ほぼ確実に masterMAC の書き間違いMaster のシリアルに出た C 配列と 1 文字ずつ照合。手打ちせずコピペで
[NG] サイズ不一致 が出る構造体の定義が Master と子機で違う両方の SensorData を見比べる。フィールドの順序・型・配列長すべて一致させる
子機が「送信結果 失敗」を返し続けるMaster の電源が入っていない / チャンネル不一致 / 距離が遠いMaster を子機の隣に置いて再試行。それで成功するなら距離か遮蔽物
/dev/ttyACM0 が現れないUSB ケーブルが充電専用データ通信対応のケーブルに交換。第2回と同じ落とし穴
/dev/ttyUSB0 の方が現れたCH340 / CP2102 搭載ボードを使っているserial_test.py の PORT をその名前に変更
Permission denied: '/dev/ttyACM0'dialout グループに入っていない3.4 を実行してログインし直す
Master をリセットしたら Pi 側スクリプトが落ちたESP32-C3 は USB がチップ内蔵なので、リセット時にポートが一度消えるスクリプト側で再オープンする作りにする (第8回の Flask 版で対応)
'wifi_tx_info_t' was not declaredESP32 ボードパッケージが 3.1 より古い本番版の #if を使うか、ボードマネージャで esp32 を更新
esp_now_recv_info が無いと言われるESP32 ボードパッケージが 2.x同上。#if 付きのコードなら通るはず
温度が -127第6回の補足を参照。断線ではなく CRC 失敗。電源を疑う4.7kΩ の位置確認 → 電源容量の確認
書き込み時に Failed to connect自動書込み回路が動かなかったBOOT を押しながら RESET、その後 BOOT を離す
書き込もうとしてもポートが選べない / デバイスマネージャーに出ない子機が DeepSleep 中。ポートごと消えているBOOT を押しながら RESET → RESET を先に離す → BOOT を離す (8.2 の直前を参照)

9.1 最大の罠 — Master を交換したとき

これは実際にやらかした事故です。

症状: Master を新品に差し替えたら、子機からのデータが一切来なくなった。子機のハードは正常 (別の Master に載せ替えると読める)。新 Master のシリアルには受信ログが 1 行も出ない。

原因: ESP-NOW は宛先 MAC アドレスで通信します。子機のスケッチに書いてある masterMAC[] は旧 Master の MAC を指したままです。子機は旧 MAC 宛に送り続け、そのチップはもう存在しないので届きません。新 Master に電波は届いていても、宛先が違うので受信コールバックが発火しないのです。

対処: 子機を全台回収して、masterMAC[] を書き換えて再書き込み。これしかありません。

ここが ESP-NOW の設計上の弱点です。 第4〜6回の WiFi 構成なら、Pi の IP が変わっても SSID とパスワードさえ同じなら子機は繋がります。ESP-NOW では Master の物理チップが唯一の宛先なので、交換すると全子機の書き換えが要ります。子機を現地に展開したあとだと、これはかなり痛い作業になります。

予防:

  • Master は交換しにくい前提で選ぶ。安さより入手性と信頼性
  • 予備機を用意するなら、先に予備機の MAC を控えておく
  • 子機のスケッチのコメントに、自分の MAC と Master の MAC を両方書いておく
// この子機の MAC : 84:CC:A8:D4:E5:F6  (sensor_id = NOW-D4E5F6)
// Master の MAC  : 84:CC:A8:1A:2B:3C  (2026-09-04 時点)
uint8_t masterMAC[] = {0x84, 0xCC, 0xA8, 0x1A, 0x2B, 0x3C};

なお Master が一時的に電源断やリセットで落ちても、MAC は変わりません。復帰すればそのまま受信を再開します。子機側の変更が必要になるのは「物理チップを別個体に替えたとき」だけです。


10. 電池はどのくらい変わるか

第6回の WiFi 版は、実測で 2 分周期・単三 3 本で 1 か月強でした (静的 IP 宣言にした場合。DHCP のままだと 2 週間弱)。

ESP-NOW にすると起きている時間が変わります。

工程WiFi + HTTP POSTESP-NOW
起動・初期化約 0.3 秒約 0.3 秒
温度取得 (DS18B20 12bit)約 0.8 秒約 0.8 秒
通信の確立2〜3 秒0 秒 (不要)
送信約 0.1 秒約 0.05 秒
1 回あたり合計約 3.2〜4.2 秒約 1.15 秒

起きている時間がおよそ 1/3 になります。

ただし、ここから電池寿命を計算した数字は書きません。 筆者は ESP-NOW 版の電池寿命をまだ実測していないからです。第6回で「DHCP の電池差は誤差レベル」と推測で書いて、1 か月後の実測にひっくり返された経緯があります。同じ轍は踏みません。

目安として「起床時間が 1/3 なら、消費も概ね 1/3 に近づく」とは言えます。ただし DeepSleep 中の待機電流や LDO の自己消費はそのまま残るので、寿命が 3 倍になるわけではありません。実測できたら追記します。

さらに詰めるなら、温度取得の 0.8 秒が最大の残りです。DS18B20 の分解能を 12bit (750ms) から 10bit (187ms) に落とせば、sensors.setResolution(10) の 1 行で 0.5 秒以上削れます。分解能は 0.0625℃ から 0.25℃ になりますが、室温監視なら十分でしょう。


11. 到達距離について

この記事の冒頭で「距離が要るなら WiFi 構成」と書きました。その根拠を補足します。

Archer T2U Plus は外付けの長いアンテナが付いた USB WiFi 子機です。一方 XIAO ESP32-C3 は基板そのものが小さく、アンテナもそれなりです。

筆者は両者の到達距離を数値で比較していません。 カタログ値の比較には意味が薄く (測定条件が違う)、実環境では壁材や金属什器の影響の方が大きいためです。設置予定の場所で実際に測るのが唯一確実な方法です。

測り方は簡単で、Master のシリアルに出る RSSI を見ればいいだけです。子機を持って歩きながら、[OK] ... RSSI=-XX の値を記録します。目安として、-70 dBm を下回るあたりから欠測が増え始めます。

XIAO ESP32-C3 に外部アンテナ用のコネクタが付いている個体をお持ちなら、接続して同じ場所で測り直すと差が分かります。

距離が足りない場合の選択肢は 3 つです。

  1. Master を子機に近い場所に置き、Master と Pi の間を長い USB ケーブルで結ぶ
  2. Master を増やす (Pi の USB ポートは複数ある)
  3. WiFi + AP 構成 (第4〜6回) に戻す

1 が一番手軽です。USB は規格上 5m まで延ばせます。


12. AI との会話例

ESP-NOW は「送信側と受信側の 2 つのスケッチが噛み合って初めて動く」仕組みなので、AI に聞くときも両方まとめて扱うのがコツです。

XIAO ESP32-C3 を 2 枚使って ESP-NOW をやりたい。片方が DS18B20 の温度を送る子機、もう片方が受信して USB シリアルに JSON を出す Master。送受信で共有する構造体は以下で固定したい。

typedef struct { char sensor_id[16]; float temp; float battery_voltage; } SensorData;

この構造体を前提に、Master と子機の .ino を 1 本ずつ、それぞれファイル全体で出力して。ESP32 ボードパッケージは 3.1 系。

コツ 1: 構造体を先に固定して渡す

ESP-NOW の事故の大半は構造体の不一致です。AI に「いい感じに作って」と頼むと、Master と子機を別々のタイミングで生成したときに微妙に違う構造体を書いてきます。こちらから構造体を提示して「これで固定」と言うのが確実です。

コツ 2: 関数の一部ではなく、ファイル全体を出してもらう

これは第6回でも書いたことですが、繰り返す価値があります。

「この関数だけ直して」と頼むと断片が返ってきます。それを既存のコードに貼り込むと、{ と } の対応が合わなくなったりインデントが崩れたりして、元の不具合とは無関係なコンパイルエラーが出ます。原因の切り分けが一気に難しくなります。

error: a function-definition is not allowed here before '{' token

これが出たら、まず貼り付け位置を疑ってください。関数の中に関数を書いてしまっているサインです。

面倒でも「ファイル全体で出して」と頼むほうが、結果的に速いです。トークンはかかりますが、詰まって 30 分溶かすよりは安い。

コツ 3: ボードパッケージのバージョンを伝える

ESP32 の Arduino Core は 2.x → 3.0 → 3.1 で ESP-NOW の API が 2 回変わっています。バージョンを言わないと、AI は自分の学習時点の書き方を出してきます。「esp32 ボードパッケージは 3.1 系」の一言で esp_now_recv_info を使うかどうかが決まります。

バージョンは Arduino IDE の ツール → ボード → ボードマネージャで「esp32」を検索すると、インストール済みのバージョンが出ます。


13. ここまでのまとめ

第4〜6回 (WiFi + AP)第7回 (ESP-NOW)
Pi 側に必要なものUSB WiFi ドングル + hostapd + dnsmasq受信役の ESP 1 台 + USB ケーブル
子機の起床時間約 3〜4 秒約 1.2 秒
到達距離長い (外付けアンテナ)短め (基板アンテナ)
宛先の指定SSID + パスワードMaster の MAC アドレス
Master 交換時—全子機の書き換えが必要
Pi 側の受け口HTTP (Flask)USB シリアル
混在可能 (両方同時に受けられる)同左

どちらが優れているという話ではありません。電池を優先するなら ESP-NOW、距離を優先するなら WiFi、というのが実務的な分かれ目です。


次回予告

第8回はいよいよ Pi 側の本体、Flask サーバーを作ります。

ここまで第6回では recv_test.py、第7回では serial_test.py という最小の受け皿でしのいできました。次回はこの 2 つを 1 つのアプリに統合します。

  • HTTP POST (第6回の WiFi 子機) と USB シリアル (第7回の Master) の両方を同時に受ける
  • SQLite に保存する
  • ブラウザでグラフを見る
  • MST-XXXXXX や NOW-XXXXXX という機械的な ID に、「冷却塔1」のような人間向けの名前を付ける

デバイスの識別を 3 層 (MAC 由来の ID / ネットワーク / 表示名) に分けてきた理由が、ここでようやく回収されます。


← 第6回 | 連載目次 | 第8回 →

コメント

タイトルとURLをコピーしました