はじめに

前回、KiCad で設計して JLCPCB に投げた基板が手元にあります。部品も全部載せました。ここまでは、まだ1バイトも動いていません。

今回はその基板に ESP-IDF v5.5.3 でファームウェアを書いて、温度・湿度・気圧・照度・CO2・粉塵を 60秒ごとに MQTT で飛ばすところまでを作ります。

そして今回も、動かしてから見つかった問題が3つあります。

  • CO2 が 32767 ppm という、本当なら即避難のような値を返してくる
  • 欠測を nan で送っていた。JSON に nan は書けません
  • 送信間隔が60秒のはずなのに、実測 60.574秒

どれも「とりあえず動く」だけなら気づかずに済んでしまうもので、3つとも実機のログを読んで初めて表に出てきたものです。原因と直し方を、ソースコードと実測値つきで書きます。

🗓️ 連載「自宅センサー基盤」#2(全4回予定)

自作のセンサー基板から Grafana のダッシュボードまで、自宅の環境を測る仕組みを丸ごと作る連載です。基板 → ファームウェア → サーバー → 可視化の4回で、1枚の基板が部屋のグラフになるところまで通します。

  • 第1回 基板設計・製造 — KiCad で回路図を描いて JLCPCB に発注し、全部手はんだで組む
  • 第2回 ファームウェア(この記事)— ESP-IDF でセンサ3種のドライバを自前で書き、MQTT で飛ばす
  • 第3回 サーバー — Mosquitto・Telegraf・InfluxDB の受け皿を Docker で立てる
  • 第4回 ダッシュボード — Grafana の8パネルで、部屋の空気を1画面にする
flowchart LR A["第1回 基板設計・製造
KiCad / JLCPCB / 手はんだ"] --> B["第2回 ファームウェア
(この記事)
ESP-IDF / I2C / MQTT送信"] B --> C["第3回 サーバー
Mosquitto / Telegraf
InfluxDB"] C --> D["第4回 ダッシュボード
Grafana"] style B fill:#e3f2fd

📝 この記事でやること

  • ESP-IDF のコンポーネント構成でファームウェアを分割する
  • センサー3種のドライバを全部自前で書いた理由(消去法で1つしか残らなかった)
  • 起動時に I2C バスを掃いて、3センサのアドレスをログに残す(BH1750 は ADDR ピンで 0x23 / 0x5C の二択)
  • 落とし穴①:CO2 の unknown センチネルが 0xFFFF ではなく 0x7FFF
  • 落とし穴②:欠測を nan で送っていた → キーごと省略する設計へ
  • 落とし穴③vTaskDelay で送信が 60.574秒に間延び → xTaskDelayUntil で 60.000 秒
  • 全基板で同じ .bin を焼き、機体差は NVS だけで吸収する
  • 認証情報はソースにもログにも置かない。Wi-Fi が無くても測定とシリアル出力は止めない
💡 対象読者

Arduino で ESP32 を触ったことがあって、「そろそろ ESP-IDF で書いてみたい」 と思っている方を想定しています。ESP-IDF のプロジェクト構成から説明するので、idf.py build を打ったことがなくても読めます。


🧭 前提:ESP-IDF の「コンポーネント」とは

コードの話に入る前に、ESP-IDF でファームウェアがどう組み立てられるのかを整理しておきます。すでにご存じの方は次の章へ飛んでください。

ESP-IDF は「フレームワーク+ビルドシステム」

ESP-IDF(Espressif IoT Development Framework)は、Espressif 公式の開発フレームワークです。Arduino のように「スケッチ1枚を書く」のではなく、機能ごとに切り分けられた「コンポーネント」を、使う分だけ選んで組み込むという形でファームウェアを組み立てます。

ここは誤解されやすいところです。Wi-Fi も MQTT も NVS も、最初から全部載っている土台ではなく、必要なら依存として宣言する部品です。宣言しなければビルドにも入りません。プロジェクトの CMakeLists.txtCOMPONENTS を絞る(または MINIMAL_BUILD プロパティを立てる)と、ビルド対象は main と共通コンポーネント、そこから辿れる依存だけまで削れます。GPIO を叩くだけの、ベアメタルに近い最小構成も普通に組めるということです。

ただし完全に「RTOS 無し」にはなりません。FreeRTOS のスケジューラは起動処理の中で必ず立ち上がり、app_main() はそのスケジューラ上のタスクの1つとして呼ばれます。土台として常にあるのはそこまでで、その上に何を積むかは自分で決める——というのが実際の姿です。

環境構築そのものは別記事にまとめてあるので、まだの方はそちらからどうぞ。

💡 ワード解説:コンポーネント / Kconfig / NVS / FreeRTOS タスク
  • コンポーネント:ESP-IDF におけるモジュールの単位。components/<名前>/ にソースと CMakeLists.txt を置くと、ビルドシステムが自動でライブラリとして拾ってくれます。他からは #include "その名前.h" で使えます
  • Kconfigidf.py menuconfig で出てくる設定メニューの定義。ビルド時に決まる値(ブローカーの既定 URI など)を置く場所です
  • NVS(Non-Volatile Storage):フラッシュ上のキー・バリュー保存領域。アプリを焼き直さなくても書き換えられるので、機体ごとに違う値の置き場所になります
  • FreeRTOS タスク:ESP-IDF が土台にしているリアルタイム OS の実行単位。app_main() もタスクの1つで、vTaskDelay() で待っている間 CPU は他のタスクに回ります

この基板のプロジェクト構成

責務ごとに5つのコンポーネントへ分けました。

sensor01/
+-- CMakeLists.txt
+-- partitions.csv            nvs を 0x9000 に固定(provision.ps1 と対応)
+-- sdkconfig.defaults        USB-Serial-JTAG コンソール等
+-- main/
|   +-- sensor01.c            app_main / サンプリングループ / 送信周期の管理
|   +-- Kconfig.projbuild     ブローカーURI・送信間隔・keepalive の既定値
+-- components/
|   +-- app_i2c/              I2Cバス初期化(SDA=6 / SCL=7)+ アドレススキャン
|   +-- sensors/              BME280 / BH1750 / SEN63C の自前ドライバ
|   +-- device_cfg/           NVS 読み出し(device_id・認証情報・送信間隔)
|   +-- net_wifi/             Wi-Fi station 接続 + バックオフ再接続
|   +-- mqtt_tx/              JSON 生成(キー省略)/ LWT / publish
+-- tools/
    +-- provision.ps1         基板ごとの NVS 書き込み
    +-- read_serial.py        非対話シリアルキャプチャ
コンポーネント 責務 設計のポイント
app_i2c I2C バスの初期化とアドレススキャン 新 API driver/i2c_master.h に統一。結果を app_i2c_probe() で他へ提供
sensors 3センサの自前ドライバ 読めなかったチャネルは NAN を返す。欠測の判断は上位に委ねる
device_cfg NVS 名前空間 devcfg の読み出し device_id 未設定でも仮 ID で動く(起動不能にしない)
net_wifi station 接続・自動再接続 1秒 → 2秒 → … → 30秒のバックオフ
mqtt_tx JSON 生成・LWT・publish isfinite() でないフィールドをキーごと落とす
main/sensor01.c サンプリングループと送信周期 読み取り5秒/送信60秒の2周期

分け方の基準は「差し替えたくなる単位」です。 センサーが増えたら sensors だけ、送信先が MQTT から HTTP に変わったら mqtt_tx だけを触ればいい、という切り方にしてあります。


🔍 センサードライバ3種を全部自分で書いた理由

いきなり結論から書くと、BME280・BH1750・SEN63C の3つとも、ドライバは自前実装です。

ESP32-S3センサー基板に接続された3種類のセンサー。右にAE-BME280モジュール、手前にBH1750モジュール、奥にファンを内蔵したSEN63C

ドライバを書く相手はこの3つ。右の小基板が AE-BME280(温度・湿度・気圧/J1)、手前が BH1750(照度/J2)、奥の黒い箱がファン内蔵の SEN63C(PM1.0〜10・CO2・温湿度/J3)。第1回では「組み上がって動いている1枚」としてこの写真を出したが、今回見てほしいのは基板ではなく、3つとも同じ1本の I2C バスにぶら下がっていて、区別はアドレス1バイトだけという一点

「車輪の再発明では」と思われるかもしれませんが、既製のコンポーネントを先に比較検討したうえで、消去法で1つしか選択肢が残らなかったという話です。

まず Component Registry を当たる

ESP-IDF には Espressif Component Registry という公式のコンポーネント配布があり、idf_component.yml に数行書くだけで依存を取ってこられます(この仕組み自体は ESP-IDF 6.0 × ESP32-S3 の記事で扱いました)。まずはここを見ます。

ハマっていれば、コマンド2行で終わっていた

先に「うまくいったときの姿」を見ておきます。題材は実在するコンポーネント espressif/bh1750(v2.0.0・Apache-2.0・ESP-IDF v5.3 以降)です。

やることは、依存を1つ足すだけです。

idf.py add-dependency "espressif/bh1750^2.0.0"
idf.py reconfigure

add-dependency名前空間/名前 + バージョン範囲という書式で、既定では main コンポーネントのマニフェストmain/idf_component.yml)に依存を書き込みます。手で書くならこうです。

# main/idf_component.yml
dependencies:
  espressif/bh1750: "^2.0.0"

あとはビルドすれば、コンポーネントマネージャが依存を再帰的に解決して managed_components/ に実体を落とし、プロジェクト直下の dependencies.lock にバージョンを固定します。ロックファイルが残るので、別の PC でも CI でも同じ版が入ります。managed_components/dependencies.lockマネージャの管理物なので手で触らない——このあたりの流儀は npm や cargo と同じです。

自分で書くのは、初期化と読み取りの呼び出しだけになります。

#include "bh1750.h"

bh1750_handle_t lux = NULL;

/* コンポーネントが持つアドレス定数は BH1750_I2C_ADDRESS_DEFAULT(0x23)だけ。
 * この基板は ADDR を High 側に配線しているので 0x5C を直接渡す。 */
ESP_ERROR_CHECK(bh1750_create(bus, 0x5C, &lux));
ESP_ERROR_CHECK(bh1750_set_measure_mode(lux, BH1750_CONTINUE_1LX_RES));

float value = NAN;
if (bh1750_get_data(lux, &value) != ESP_OK) {
    value = NAN;   /* 読めなければ欠測として上へ返す */
}

bh1750_create() に渡している busi2c_new_master_bus() で作ったバスハンドルです。アドレスは uint8_t をそのまま受け取る引数なので、この基板の 0x5C も問題なく渡せます(後述のとおり 0x23 / 0x5C は ADDR ピンで決まる二択です)。コマンド値も CRC も待ち時間も、こちら側には一切出てきません。 これが Registry がハマったときの姿で、3センサともこうなっていたら、ドライバの話はこの2コマンドと十数行のコードで終わっていました。

実際には、そうはなりませんでした。差はやることの数にそのまま出ます。

ルート 手順 中身を知る必要 体感
Registry がハマる add-dependencyreconfigure → 初期化と読み取りを呼ぶ 不要。決めるのはバージョン範囲くらい 数分
自前で書く データシートとリファレンス実装を読み、コマンド値・CRC・待ち時間・欠測センチネルを自分で確定させる 全部必要 この記事の 🧪 落とし穴① の調査がセンサーごとに乗る

このあと書く「Read Measured Values は 0x0471」も「unknown は 0x7FFF」も、Registry にコンポーネントがあれば一度も踏まなかった類の話です。だからこそ、まずここを当たります。

💡 ワード解説:マニフェスト(idf_component.yml)は「あれば読まれる」ファイル

マニフェストはコンポーネントごとに置けます(idf.py create-manifest で雛形が作れます)。依存が要らないコンポーネントには置かなくてよい設計で、新しくマニフェストを追加したときだけ idf.py reconfigure を手で1回叩きます。以降はビルドが idf_component.yml の変更を追ってくれます。

実際にレジストリから取得して実機まで通した手順は ESP-IDF 6.0×ESP32-S3 で cJSON を取得して HTTP POST にログ付きでまとめてあります。

そのうえで、今回の3センサを当たった結果が次の表です。

センサー Registry のコンポーネント 判断
BME280 espressif/bme280 0.1.1 不採用espressif/i2c_bus driver/i2c.h API)に依存している
BH1750 espressif/bh1750 2.0.0 新 API 対応。だが統一のため不採用
SEN63C 存在しない 自前実装が必須

決め手は「新旧の I2C API は同一ポートで併用できない」

flowchart TD A["SEN63C に Registry の
コンポーネントが無い"] --> B["SEN63C は自前実装が確定"] C["BME280 のコンポーネントは
旧 driver/i2c.h に依存"] --> D["新旧の I2C API は
同じポートで併用できない"] B --> E["1バスに3センサを
共存させるには…"] D --> E E --> F["全部を新 driver/i2c_master.h で
統一するしかない = 3つとも自前"] style F fill:#e8f5e9

ESP-IDF v5 系には I2C のドライバが2世代あります。v4 時代からの driver/i2c.h(レガシー)と、v5 で入った driver/i2c_master.h(新 API)です。そしてこの2つは、同じ I2C ポートに対して同時には使えません。

この基板は第1回のとおり 1バス(SDA=GPIO6 / SCL=GPIO7)に3センサをぶら下げています。つまり3つのドライバは同じポートを共有します。 SEN63C を新 API で自前実装することが確定している以上、BME280 のコンポーネントが旧 API に依存している時点で、共存の芽が消えます。

結果、3つとも新 API で書くのが唯一の道でした。BH1750 だけは新 API 対応のコンポーネントがありましたが、3つのうち2つが自前になるなら、タイムアウトとスキップの方針を3センサで完全に同じにできるほうが後々効きます。

💡 ワード解説:レガシードライバと新ドライバ

ESP-IDF は v5 世代で、周辺機能のドライバを「設定構造体を渡してハンドルを受け取り、ハンドル越しに操作する」という形に作り替えました。I2C もその1つです。

古い書き方は当面動きますが、保守は新 API 側に一本化されています。全体像は ESP-IDF 6.0 移行ガイドにまとめました。これから新規に書くなら、迷わず新 API 側です。

SEN63C の落とし穴:Read Measured Values は 0x0471

SEN63C のコマンド値とスケーリングは、Sensirion 公式の embedded-i2c-sen63c にある sen63c_i2c.c / .h から確認して実装しました。ここに踏みやすい落とし穴が1つあります。

/* Verified against Sensirion embedded-i2c-sen63c. */
#define CMD_START_CONTINUOUS_MEASUREMENT 0x0021
#define CMD_STOP_MEASUREMENT             0x0104
#define CMD_GET_DATA_READY               0x0202
#define CMD_READ_MEASURED_VALUES         0x0471

測定値の読み出しコマンドは 0x0471 検索で出てくる SEN6x 系のサンプルは SEN66 の 0x0300 を使っているものが多く、同じ SEN6x ファミリでも別のコマンドです。型番が近いからと他機種のコードを流用すると、ここで無応答やゴミデータを踏みます。

ワイヤフォーマットにもクセがあります。

  • コマンドは 16bit を MSB ファーストで送る
  • 応答は 16bit ワードごとに CRC-8 が1バイト付く
  • 書き込みと読み出しの間に、ストップコンディションとコマンド別の待ち時間が必要

最後の1点があるので、この基板では i2c_master_transmit_receive() を使わず、送信と受信を別々に発行しています。「1関数でまとめて発行できる」便利機能が、どのデバイスでも使えるとは限らない例です。

I2C そのものの仕組みは I2C・SPI・UART の違い にまとめてあります。


📡 起動時に I2C バスを掃いて、アドレスをログに残す

ファームウェアは起動直後に、0x08〜0x77 を総当たりでプローブします。

ESP_LOGI(TAG, "--- I2C scan (0x08..0x77) ---");
for (uint8_t addr = 0x08; addr <= 0x77; addr++) {
    if (!app_i2c_probe(addr)) {
        continue;
    }
    ESP_LOGI(TAG, "  found device @ 0x%02X", addr);
    ...
}
ESP_LOGI(TAG, "--- I2C scan done: %u device(s) ---", (unsigned)n);

実機の起動ログがこれです。

I (109) app_i2c: I2C bus ready (SDA=GPIO6 SCL=GPIO7, 100000 Hz)
I (109) app_i2c: --- I2C scan (0x08..0x77) ---
I (122) app_i2c:   found device @ 0x5C
I (124) app_i2c:   found device @ 0x6B
I (126) app_i2c:   found device @ 0x76
I (126) app_i2c: --- I2C scan done: 3 device(s) ---
I (142) bme280: initialised at 0x76 (chip id 0x60)
I (324) bh1750: initialised at 0x5C (continuous H-resolution mode)
I (1774) sen63c: initialised at 0x6B (continuous measurement started)
I (1774) sensor01: sensor availability: BME280=yes BH1750=yes SEN63C=yes

アドレスは ADDR ピンで決まる — BH1750 は設計どおりの 0x5C

出てきた3つは、どれも設計どおりの値です。せっかくなので、この I2C アドレスがどうやって決まっているのかを見ておきます。1本のバスに3つぶら下げる以上、ここが重なったら何も読めなくなる部分です。

センサー 取りうるアドレス 決め方 この基板
BME280 0x76 または 0x77 ストラップピンの H/L 0x76
BH1750 0x23 または 0x5C ADDR ピンの H/L 0x5C(ADDR を High 側に配線)
SEN63C 0x6B のみ 固定(選択不可) 0x6B

BH1750 のデータシートやサンプルコードで真っ先に出てくるのは 0x23 ですが、これは ADDR ピンを Low 側にしたときの値です。High 側にすると 0x5C になります。この基板は ADDR を High 側に配線しているので、0x5C が設計値です。

つまり「標準アドレス」と呼ばれている値は、チップが1つに決めた値ではなく、ピン1本で選べる2つのうちの片方にすぎません。BME280 も同じ構造で、ストラップピンの H/L によって 0x76 と 0x77 を取ります。逆に SEN63C は 0x6B 固定で、選ぶ余地がありません。

それでもファームウェア側は、アドレスをハードコードしていません。 2つの候補を順に試して、応答したほうを採用する書き方にしてあります。

/* BME280 and BH1750 each have two possible addresses depending on a strap pin,
 * so try both rather than assuming one. */
static void init_bh1750(void)
{
    static const uint8_t candidates[] = { BH1750_ADDR_PRIMARY, BH1750_ADDR_SECONDARY };

    for (size_t i = 0; i < sizeof(candidates); i++) {
        if (!app_i2c_probe(candidates[i])) {
            continue;
        }
        if (bh1750_init(candidates[i]) == ESP_OK) {
            s_bh.available = true;
            s_bh.addr = candidates[i];
            return;
        }
    }
    s_bh.available = false;
    ESP_LOGE(TAG, "%s not responding at 0x%02X or 0x%02X - skipping this sensor",
             s_bh.name, BH1750_ADDR_PRIMARY, BH1750_ADDR_SECONDARY);
}

BME280 も同じ構造で 0x76 / 0x77 の両方を試し、さらに chip id が 0x60 であることまで確認しています。BMP280(気圧のみ・chip id は 0x58)が間違って載っていた場合に、湿度を読もうとして初めて気づく、という遠回りを避けるためです。

✅ I2C アドレスは「決まっている」のではなく「選ぶ」もの

1. データシートの「標準アドレス」は、ピン1本の配線で変わる BH1750 も BME280 も、ストラップピン1本で2つの値を取ります。どちらになるかを決めているのはチップではなく、基板側の配線です。先頭に出てくる値をそのまま定数に埋めると、配線を変えた版で動かなくなります。

2. 起動時スキャンは10行で書けて、ずっと効く 0x08〜0x77 を舐めるだけです。実アドレスがログに残るので、狙った配線になっているかがその場で分かりますし、次に基板を起こしたときも、センサーを載せ替えたときも、まず最初に見る場所ができます。

3. 候補を順に試しておくと、次の基板でファームを書き換えずに済む ADDR の配線を変えた版を作っても、モジュールを別のものに差し替えても、同じバイナリのまま動きます。 1バイトの定数のために焼き直す作業が、そもそも発生しません。


🧪 落とし穴①:CO2 が 32767 ppm — unknown は 0xFFFF ではなく 0x7FFF

センサーが3つとも初期化できて、値も読めるようになりました。そこで CO2 を見ると、こうなっていました。

co2 = 32767 ppm

32767 ppm は 3.3% です。本当ならその部屋には居られません。そして 32767 という数字には見覚えがあります。0x7FFF、符号付き16bit の最大値です。

原因:unknown を表す値が、同じセンサー内で2種類ある

SEN63C は測定値を 16bit ワードで返し、まだ値が無いチャネルには「unknown」を意味する固定値を入れてきます。実装時、この値を PM 系の記載に合わせて 0xFFFF だと思い込んでいました。

Sensirion 公式ヘッダの CO2 の記述は、こうなっています。

@param[out] co2 CO₂ concentration [ppm] Note: If this value is unknown, 0x7FFF is returned. During the first 22..24 seconds starting a measurement, this value will be 0x7FFF.

PM 系の unknown は 0xFFFF、CO2 だけ 0x7FFF 同じセンサーの同じ応答フレームの中で、チャネルによって unknown の表現が違います。理由は型で、PM は符号なし16bit、温湿度と CO2 は符号付き16bitとして定義されているためです。符号付きなら「最大値」は 0x7FFF になります。

対処と確認

判定をチャネルの型に合わせて分けました。

#define UNKNOWN_U16 0xFFFFu
#define UNKNOWN_S16 0x7FFFu

*pm1_0 = (v[0] == UNKNOWN_U16) ? NAN : (float)v[0] / 10.0f;
...
*humidity_pct  = (v[4] == UNKNOWN_S16) ? NAN : (float)(int16_t)v[4] / 100.0f;
*temperature_c = (v[5] == UNKNOWN_S16) ? NAN : (float)(int16_t)v[5] / 200.0f;

/* Datasheet: CO2 unknown is 0x7FFF, not 0xFFFF, and it stays 0x7FFF for
 * the first 22-24 s of a measurement. */
*co2_ppm = (v[6] == UNKNOWN_S16) ? NAN : (float)v[6];

直したあとの実機の挙動が、公式の記述どおりになりました。起動から約24秒は CO2 が欠測扱いになり、そのあと実値へ移ります。

起動〜約24秒 : co2 = 欠測(キー自体を送らない)
その後       : 390 → 442 → 493 ppm

スケーリング係数もチャネルごとに違う(湿度は 1/100、温度は 1/200、PM は 1/10)ので、ここも1つずつ確認して実装しています。

📌 確認のしかた:おかしな値は、まず16進で書き直す

32767 は 0x7FFF、65535 は 0xFFFF、-32768 は 0x8000センサーの値が跳ねたら、まずその数字を16進に直して「型の端」に当たっていないかを見ます。 当たっていれば、それは測定値ではなく無効値です。

そして unknown 表現は、チャネルごとにデータシートを引き直す。1つのセンサーの中でも、型もスケールも unknown の値も揃っているとは限りません。


🕳️ 落とし穴②:欠測はキーごと消す — nan を送らない

こちらは、シリアルに JSON を吐くところまで作った段階での話です。値が読めなかったチャネルを、こう出力していました。

TELEMETRY {"device_id":"sensor_001","temperature_bme":nan,"humidity_bme":nan,...}

JSON に nan は書けません。 これは実装の好みの問題ではなく、JSON の数値リテラルに nan / inf が無いという規格の話です。多くのパーサはこれを構文エラーにします。加えて、この構成の MQTT 仕様書側でも「欠測はキーごと省略し、null もセンチネル値も送らない」と決めていたので、二重に違反していました。

フィールド単位で isfinite() を見て、キーを落とす

ドライバは読めなかったチャネルに NAN を入れて返し、JSON を組む側がキーごと捨てます。

/* NAN is how the drivers report "this channel had no value this cycle",
 * including the SEN63C 22-24 s CO2 warm-up. Both mean: leave the key out. */
static void j_float(jctx_t *j, const char *key, float v, int decimals)
{
    if (!isfinite(v)) {
        return;
    }
    j_raw(j, ",\"%s\":%.*f", key, decimals, (double)v);
    j->fields++;
}

ポイントは、判定がセンサー単位ではなくフィールド単位であることです。

flowchart LR S1["BME280
温度 / 湿度 / 気圧"] --> J{"isfinite?
フィールドごとに判定"} S2["BH1750
照度"] --> J S3["SEN63C
PM4種 / 温湿度 / CO2"] --> J J -->|"値がある"| K["キーを出力"] J -->|"NAN"| X["キーを丸ごと省略"] K --> P["publish"] X -.->|"1つも残らなければ"| N["publish 自体を見送る"] style X fill:#fff3e0 style N fill:#ffebee

同じ仕組みから、実機では次の3パターンが出てきます。上から順に見ると、消え方の粒度が分かります。

① 全センサー正常(11フィールド)

{"device_id":"sensor_001","temperature_bme":29.10,"humidity_bme":40.70,"pressure":1002.99,
 "lux":77.5,"temperature_sen":27.00,"humidity_sen":54.40,"co2":394,
 "pm1_0":5.4,"pm2_5":6.5,"pm4_0":7.2,"pm10":7.5,
 "rssi":-37,"uptime_s":196,"heap_free":263944}

② CO2 ウォームアップ中(co2 だけが消える)

{"device_id":"sensor_001","temperature_bme":28.38,"humidity_bme":42.25,"pressure":1003.34,
 "lux":71.7,"temperature_sen":28.49,"humidity_sen":49.86,
 "pm1_0":5.5,"pm2_5":6.6,"pm4_0":7.4,"pm10":7.8,"uptime_s":16,"heap_free":325372}

③ SEN63C がまだ立ち上がっていない(7キーがまとめて消える)

{"device_id":"sensor_001","temperature_bme":28.39,"humidity_bme":41.90,"pressure":1003.36,
 "lux":71.7,"uptime_s":1,"heap_free":325372}

②では同じ SEN63C の PM と温湿度は生きたまま、CO2 だけが落ちています。センサー単位で「SEN63C は NG」と切り捨てる実装だと、ここで PM のデータまで捨てることになります。

さらに、1フィールドも残らなかったサイクルは publish 自体を見送ります。

/* Spec S4 rule 5: a message carrying only device_id is not sent at all -
 * the state topic is where a total sensor failure gets reported. */
if (j.fields == 0) {
    return 0;
}

device_id だけの空メッセージを毎分投げても、受け取る側には「送信元が生きている」以上の情報がありません。その情報は state トピック側の役目なので、telemetry では黙る、という切り分けです。

✅ なぜ「キーを消す」が正解になるのか

サーバー側(第3回で扱います)は Telegraf の JSON パーサで受けます。このパーサは「未知のキーは自動でフィールド化・欠落したキーは無視・null は無視」という挙動です。

つまり、キーが消えても増えても、サーバー側の設定は1行も変わりません。 搭載センサーが機体ごとに違っても、センサーが1つ落ちても、受け皿はそのままで済みます。

逆に -999 のようなセンチネル値を送ると、時系列DBには「-999という測定値」として入ります。 グラフは大きく振れ、平均値は壊れます。欠測は「無い」と表現するのがいちばん安全です。ここで消したキーは、第4回のダッシュボードでグラフの穴として素直に表示されます。


🏭 全基板で同じ .bin、違うのは NVS だけ

この基板は複数枚に増やす前提です。となると最初に決めるべきなのは、機体ごとの差をどこに置くかでした。

#define DEVICE_ID "sensor_001"   /* これは最初の1枚で詰む */

2枚目を作った瞬間に書き換えて再ビルドが必要になり、3枚目で「いまフラッシュに入っているのはどの版か」が分からなくなります。Wi-Fi のパスワードをソースに書けば、リポジトリに認証情報が入るというおまけまで付いてきます。

機体差は全部 NVS に置く

flowchart TD BIN["sensor01.bin
(全基板で同一)"] --> B1["基板 #1"] BIN --> B2["基板 #2"] BIN --> B3["基板 #3"] N1["nvs: device_id=sensor_001
認証情報"] --> B1 N2["nvs: device_id=sensor_002
認証情報"] --> B2 N3["nvs: device_id=sensor_003
認証情報"] --> B3 style BIN fill:#e3f2fd style N1 fill:#e8f5e9 style N2 fill:#e8f5e9 style N3 fill:#e8f5e9

NVS 名前空間 devcfg に置いているキーはこれだけです。

キー 内容
device_id string sensor_001 等。未設定なら仮 ID で動作
wifi_ssid / wifi_pass string Wi-Fi 認証情報
mqtt_pass string MQTT パスワード(基板ごとに異なる
mqtt_uri string(任意) 空ならファーム既定
telemetry_interval_s u32(任意) 未設定ならファーム既定の60秒

MQTT のユーザー名は device_id と同一にしてあるので、mqtt_user は持たせていません。同じ意味の値を2か所に置くと、食い違ったときのほうが厄介だからです。

telemetry_interval_s10〜3600秒の範囲に丸めます。 範囲外の値を焼いてしまっても、警告を出して丸めるだけで起動は続きます。設定ミスで文鎮にしないための実装です。

2枚目からは、nvs パーティションだけ数秒

partitions.csvnvs を 0x9000 に固定してあるので、書き込みツールはパーティションテーブルを解析せずに、この番地へ直接焼けます。

Created NVS binary: ===> ...\sensor01_nvs_c47b38b6.bin
Writing nvs partition at 0x9000 on COM6 ...
Wrote 24576 bytes (132 compressed) at 0x00009000 ... Hash of data verified.
Provisioned COM6 as sensor_001.

再起動後のログで、ちゃんと読めていることが分かります。

I (107) device_cfg: device_id = sensor_001
I (108) sensor01: device_id: sensor_001

このときアプリ本体は1バイトも書き込んでいません。 焼いたのは nvs パーティションだけです。2枚目は -DeviceId sensor_002 に変えて同じことをするだけで、リビルドは不要です。

プロビジョニングを忘れても、データは混ざらない

未設定の基板は、MAC アドレスの下位3バイトから作った仮 ID で動きます。

I (108) sensor01: device_id: sensor_unprov_xxxxxx  <-- PLACEHOLDER, NOT PROVISIONED

(実際には下3バイトの16進が入ります)

「起動を止めてエラーにする」より、こちらを選びました。仮 ID は基板ごとに違う値になるので、プロビジョニングを忘れた基板が2枚あっても、サーバー側で系列が混ざりません。 ログには NOT PROVISIONED と出るので、気づくのは簡単です。

📌 1枚しか作らないつもりでも、機体差はフラッシュに置く

「同じバイナリを N 枚に焼く」構成にしておくと、ビルド成果物が1つになります。 どの基板に何が入っているかを覚える必要がなくなり、更新は全台に同じ .bin を配るだけです。

そして認証情報がソースツリーから消えます。 リポジトリに入るのは device_cfg.example.csv のような空のテンプレートだけになり、実ファイルは .gitignore の内側に置けます。


⏱️ 落とし穴③:5秒と60秒の2周期 — vTaskDelay は「周期」ではない

3つめは、いちばん地味で、いちばん効いた問題でした。

まず、なぜ2周期なのか

このファームウェアは5秒ごとにセンサーを読み、60秒ごとに送信します。読み取りを60秒間隔に落とすのではありません。

flowchart LR subgraph L["5秒ループ"] R1["読み取り"] --> R2["読み取り"] --> R3["…"] --> R12["読み取り
×12回"] end L --> P["60秒ごとに
最新サンプルを1通publish"] L --> C["シリアルには
5秒ごとに出力"] style P fill:#e3f2fd style C fill:#e8f5e9

理由は2つあります。シリアルログが5秒ごとに生きているので、センサーの脱落や値のふらつきがすぐ見えること。そして送信だけを絞れば、ネットワークとサーバーの負荷は60秒間隔のままでいられることです。

同じループの中で、60秒ごとに未応答センサーの再プローブもしています。応答が無いセンサーはスキップして他を送り続け、コネクタを挿し直せば再起動なしで復帰するという設計です。

症状:60秒のはずが 60.574秒

送信間隔を実測したところ、こうなっていました。

区間 実測間隔
修正前 60.574 秒
狙い 60.000 秒

0.574秒。1回だけ見れば誤差のようなものですが、これは毎分積み上がります。

原因:vTaskDelay は「処理が終わってから N ミリ秒待つ」

メインループは、こういう形をしていました。

[読み取り 約50ms] -> [vTaskDelay(5000ms)] -> [読み取り 約50ms] -> [vTaskDelay(5000ms)] -> ...

vTaskDelay(5000) は「5秒周期で起こしてくれ」ではなく「いまから5秒待て」という意味です。したがって1サイクルの実時間は次のようになります。

T_{\mathrm{cycle}} = 5000 + t_{\mathrm{work}} \;[\mathrm{ms}]

I2C で3センサを読む時間 t_{\mathrm{work}} は約 50ms。送信は12サイクルに1回なので、送信間隔は次の値になります。

T_{\mathrm{publish}} = 12 \times (5000 + 50) = 60600 \;[\mathrm{ms}]

実測の 60.574秒とほぼ一致します。50ms という、単体では誰も気にしない値が、12倍されて 0.6秒になっていたわけです。

影響:1時間で約34秒ずれて、グラフに穴が空く

1回 0.574秒のずれが1時間ぶん積み上がると、こうなります。

0.574 \times 60 \simeq 34 \;[\mathrm{s}]

つまり1時間で約34秒、送信タイミングが後ろへずれていきます。

サーバー側は受信時刻を採用するので、データベースは壊れません。 壊れるのは見え方のほうです。第4回で作る Grafana のパネルは、60秒送信に合わせて1分解像度で描きます。その解像度で折れ線にすると、こうなります。

flowchart LR A["12:00:00
1点"] --> B["12:01:00
1点"] --> C["…0.574秒ずつ
後ろへずれていく…"] --> D["13:44:59
1点"] --> E["13:45
点が0個"] --> F["13:46:00
1点"] style E fill:#ffcdd2

送信間隔が60秒より長いので、1分の枠に点が2つ入ることはありません。入るのは0個か1個です。問題は、ずれが積み上がってちょうど1分ぶんに達した瞬間に起きます。

60 \div 0.574 \simeq 104.5 \;[\text{回}]

105回目あたりの送信で、点が1個も入らない分が1つできます。 時間にして約105分ごと。折れ線はそこで途切れ、以降も同じ間隔で穴が空き続けます。「センサーが落ちた」ように見えるのに、ログを見ても正常という、いちばん時間を溶かすパターンです。

対処:xTaskDelayUntil で「絶対時刻」で起きる

直し方は1行です。

/* The two periods below are counted in whole SAMPLE_PERIOD_MS steps, so the
 * loop has to wake on an exact period rather than "however long the work
 * took, plus the delay". With a plain vTaskDelay the ~50 ms of I2C work per
 * cycle accumulated into a 60.6 s publish interval, which drifts a whole
 * sample in and out of each minute and leaves gaps in a 1-minute Grafana
 * panel. xTaskDelayUntil absorbs the work time instead. */
TickType_t last_wake = xTaskGetTickCount();

while (true) {
    /* ...読み取り・publish・再プローブ... */
    xTaskDelayUntil(&last_wake, pdMS_TO_TICKS(SAMPLE_PERIOD_MS));
}

xTaskDelayUntil() は「前回起きた時刻+5000ms に起こしてくれ」という API です。last_wake を関数側が更新していくので、処理に何ミリ秒かかったかに関係なく、起床時刻は 5000ms 刻みのままになります。処理時間はそのぶん待ち時間から差し引かれる、という動きです。

vTaskDelay(5000) xTaskDelayUntil(&last, 5000)
意味 いまから5000ms 待つ 前回の起床から5000ms 後に起こす
実周期 5000 + 処理時間 5000(処理時間を吸収)
誤差 毎周期たまる たまらない
処理が5000msを超えたら そのぶん遅れる 即座に次へ(周期は守れない)

結果:60.001 秒 / 60.000 秒

再ビルドして焼き直し、もう一度キャプチャした実測がこれです。

I  (57028) mqtt_tx: -> home/sensor/sensor_001/telemetry (237 B, msg_id=37556)
I (117029) mqtt_tx: -> home/sensor/sensor_001/telemetry (248 B, msg_id=16511)
I (177029) mqtt_tx: -> home/sensor/sensor_001/telemetry (248 B, msg_id=4900)
区間 修正前 修正後
1回目 → 2回目 60.574 秒 60.001 秒
2回目 → 3回目 60.574 秒 60.000 秒

同じキャプチャの中で、読み取りは5秒ごとに走り続けています(200秒で TELEMETRY 行が40本)。送信だけが60秒に集約されていることも、これで確認できました。

📌 確認のしかた:周期は1回ではなく、積み上がったずれで見る

1回ぶんのずれは、シリアルログを眺めている限り気づけない大きさです。症状になったのは、1時間ぶん積み上がって1分解像度のグラフに穴が空いたときでした。

周期を確かめるなら、1回の間隔ではなく 100 回後に絶対時刻がどれだけずれているかを見ます。ずれているなら、vTaskDelayxTaskDelayUntil に替えるだけです。


🔐 認証情報を出さない・ネットが無くても止めない

残りの2つは、地味ですが最初に決めておくと後で楽になる部分です。

認証情報をログに出さない

  • パスワードは値も文字数もログに出さない。認証に失敗したときも「mqtt_pass と当該アカウントを確認」という文面だけを出す
  • NVS から読んだ平文は、渡した直後に memset でゼロクリアする
  • Kconfig(=ビルド時に決まる値)に入れたのはブローカー URI・送信間隔・keepalive だけ。認証情報は NVS のみ
  • 認証情報の実ファイルと、シリアルキャプチャのログは .gitignore の内側に置く
  • プロビジョニングのスクリプトが作る平文の一時ファイルは %TEMP% に置き、finally で必ず削除する
⚠️ 自分のコードが出さなくても、Wi-Fi ドライバは SSID を出す

ここまでやっても、ESP-IDF の Wi-Fi ドライバ自身が接続時に SSID を1行ログへ出します。 自作コード側をいくら締めても消えません。

抑止したいなら次の1行で消せます。

esp_log_level_set("wifi", ESP_LOG_WARN);

ただしWi-Fi ドライバの診断ログも一緒に消えるので、切り分けが必要な段階では残しておくほうが実用的です。ログをそのまま記事やイシューに貼るときは、SSID と BSSID のマスクを忘れずに。

認証情報が無くても起動する

Wi-Fi の設定が NVS に無い基板は、警告を出したうえで、そのまま動きます。

W (1895) sensor01: no Wi-Fi credentials in NVS (ESP_ERR_NVS_NOT_FOUND) - running offline;
                   sensor readings continue on the console only
W (1896) sensor01: run tools/provision.ps1 with wifi_ssid/wifi_pass filled in to go online

「ネットワークはベストエフォート」という方針を、この2行がそのまま表しています。Wi-Fi もブローカーも落ちてよく、センサーの読み取りとシリアル出力は止めない。 ブローカーが切れている間の telemetry は保留され、再接続した最初のサンプルで即座に送られます(60秒待ちません)。

センサーを組み込んだ機器では、「測る」と「送る」を独立させておくと切り分けが速くなります。値が出ていなければセンサー側、値は出ているのに届かないならネットワーク側、と一目で分かるからです。


📦 ビルド結果

最後にサイズです。

sensor01.bin binary size 0xdb680 bytes. Smallest app partition is 0x177000 bytes.
0x9b980 bytes (41%) free.
Project build complete.

Wi-Fi と MQTT のスタックを積んで、factory パーティション(1500K)に 41% の余裕、警告0件でした。TLS を足しても、OTA 用の領域を切ってもまだ動ける余地があります。

ビルドと書き込みは eim 経由で回しています。コマンドの形と、複数バージョンの共存については EIM の使い方 にまとめました。


✅ まとめ

📌 この記事のポイント
  • ドライバ3種の自前実装は消去法の結果。 SEN63C に Registry のコンポーネントが無く、新旧の I2C API は同一ポートで併用できないため、1バス3センサなら全部を新 API に統一するしかない
  • SEN63C の Read Measured Values は 0x0471 SEN66 の 0x0300 とは別。同じファミリでも他機種のコードは流用できない
  • I2C アドレスはピン1本で選ぶもの。 BH1750 は ADDR の H/L で 0x23 / 0x5C の二択で、この基板は High 側に配線しているので 0x5C が設計値。候補を両方試す実装にしてあるので、配線を変えた版でもファームは同じまま
  • CO2 の unknown は 0x7FFF(PM 系は 0xFFFF)。異常値 32767 を16進で書き直したら原因に行き着いた。データシートは項目ごとに読む
  • 欠測はキーごと省略する。 nan は JSON の規格外。判定はフィールド単位なので、CO2 だけ落ちても PM は送られる
  • 全基板で同じ .bin、機体差は NVS だけ。 #define DEVICE_ID は最初の1枚で詰む。2枚目からは nvs パーティションだけを数秒
  • vTaskDelay は周期ではない。 50ms の処理時間が12倍されて 60.574秒になり、1時間で約34秒ずれてグラフに穴が空いた。xTaskDelayUntil で 60.000 秒へ

この連載のソースコード全文は GitHub で公開しています。本記事のファームウェアは ramtuc/homesensorfirmware/ ディレクトリです。

基板が値を出して、60秒ごとに MQTT で飛ぶようになりました。次回はその受け手を作ります。Mosquitto を匿名禁止で立てて、1デバイス1アカウント+ACL でなりすましを構造的に止める設計にし、Telegraf で InfluxDB へ流し込むところまでです。今回「キーを消す」ことにこだわった理由が、そこで効いてきます。


関連記事