はじめに
前回、KiCad で設計して JLCPCB に投げた基板が手元にあります。部品も全部載せました。ここまでは、まだ1バイトも動いていません。
今回はその基板に ESP-IDF v5.5.3 でファームウェアを書いて、温度・湿度・気圧・照度・CO2・粉塵を 60秒ごとに MQTT で飛ばすところまでを作ります。
そして今回も、動かしてから見つかった問題が3つあります。
- CO2 が 32767 ppm という、本当なら即避難のような値を返してくる
- 欠測を
nanで送っていた。JSON にnanは書けません - 送信間隔が60秒のはずなのに、実測 60.574秒
どれも「とりあえず動く」だけなら気づかずに済んでしまうもので、3つとも実機のログを読んで初めて表に出てきたものです。原因と直し方を、ソースコードと実測値つきで書きます。
自作のセンサー基板から Grafana のダッシュボードまで、自宅の環境を測る仕組みを丸ごと作る連載です。基板 → ファームウェア → サーバー → 可視化の4回で、1枚の基板が部屋のグラフになるところまで通します。
- 第1回 基板設計・製造 — KiCad で回路図を描いて JLCPCB に発注し、全部手はんだで組む
- 第2回 ファームウェア(この記事)— ESP-IDF でセンサ3種のドライバを自前で書き、MQTT で飛ばす
- 第3回 サーバー — Mosquitto・Telegraf・InfluxDB の受け皿を Docker で立てる
- 第4回 ダッシュボード — Grafana の8パネルで、部屋の空気を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.txt で COMPONENTS を絞る(または MINIMAL_BUILD プロパティを立てる)と、ビルド対象は main と共通コンポーネント、そこから辿れる依存だけまで削れます。GPIO を叩くだけの、ベアメタルに近い最小構成も普通に組めるということです。
ただし完全に「RTOS 無し」にはなりません。FreeRTOS のスケジューラは起動処理の中で必ず立ち上がり、app_main() はそのスケジューラ上のタスクの1つとして呼ばれます。土台として常にあるのはそこまでで、その上に何を積むかは自分で決める——というのが実際の姿です。
環境構築そのものは別記事にまとめてあるので、まだの方はそちらからどうぞ。
- VS Codeで始めるESP-IDF環境構築ガイド — 拡張機能から Hello World まで
- EIM(ESP-IDF Installation Manager)の使い方 — 複数バージョンを共存させる。この基板も eim 管理下の v5.5.3 で書いています
- コンポーネント:ESP-IDF におけるモジュールの単位。
components/<名前>/にソースとCMakeLists.txtを置くと、ビルドシステムが自動でライブラリとして拾ってくれます。他からは#include "その名前.h"で使えます - Kconfig:
idf.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つとも、ドライバは自前実装です。
ドライバを書く相手はこの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() に渡している bus は i2c_new_master_bus() で作ったバスハンドルです。アドレスは uint8_t をそのまま受け取る引数なので、この基板の 0x5C も問題なく渡せます(後述のとおり 0x23 / 0x5C は ADDR ピンで決まる二択です)。コマンド値も CRC も待ち時間も、こちら側には一切出てきません。 これが Registry がハマったときの姿で、3センサともこうなっていたら、ドライバの話はこの2コマンドと十数行のコードで終わっていました。
実際には、そうはなりませんでした。差はやることの数にそのまま出ます。
| ルート | 手順 | 中身を知る必要 | 体感 |
|---|---|---|---|
| Registry がハマる | add-dependency → reconfigure → 初期化と読み取りを呼ぶ |
不要。決めるのはバージョン範囲くらい | 数分 |
| 自前で書く | データシートとリファレンス実装を読み、コマンド値・CRC・待ち時間・欠測センチネルを自分で確定させる | 全部必要 | この記事の 🧪 落とし穴① の調査がセンサーごとに乗る |
このあと書く「Read Measured Values は 0x0471」も「unknown は 0x7FFF」も、Registry にコンポーネントがあれば一度も踏まなかった類の話です。だからこそ、まずここを当たります。
マニフェストはコンポーネントごとに置けます(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 は同一ポートで併用できない」
コンポーネントが無い"] --> 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)が間違って載っていた場合に、湿度を読もうとして初めて気づく、という遠回りを避けるためです。
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] co2CO₂ 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つずつ確認して実装しています。
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++;
}
ポイントは、判定がセンサー単位ではなくフィールド単位であることです。
温度 / 湿度 / 気圧"] --> 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 に置く
(全基板で同一)"] --> 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_s は 10〜3600秒の範囲に丸めます。 範囲外の値を焼いてしまっても、警告を出して丸めるだけで起動は続きます。設定ミスで文鎮にしないための実装です。
2枚目からは、nvs パーティションだけ数秒
partitions.csv で nvs を 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 と出るので、気づくのは簡単です。
「同じバイナリを N 枚に焼く」構成にしておくと、ビルド成果物が1つになります。 どの基板に何が入っているかを覚える必要がなくなり、更新は全台に同じ .bin を配るだけです。
そして認証情報がソースツリーから消えます。 リポジトリに入るのは device_cfg.example.csv のような空のテンプレートだけになり、実ファイルは .gitignore の内側に置けます。
⏱️ 落とし穴③:5秒と60秒の2周期 — vTaskDelay は「周期」ではない
3つめは、いちばん地味で、いちばん効いた問題でした。
まず、なぜ2周期なのか
このファームウェアは5秒ごとにセンサーを読み、60秒ごとに送信します。読み取りを60秒間隔に落とすのではありません。
×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サイクルの実時間は次のようになります。
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分解像度で描きます。その解像度で折れ線にすると、こうなります。
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回の間隔ではなく 100 回後に絶対時刻がどれだけずれているかを見ます。ずれているなら、vTaskDelay を xTaskDelayUntil に替えるだけです。
🔐 認証情報を出さない・ネットが無くても止めない
残りの2つは、地味ですが最初に決めておくと後で楽になる部分です。
認証情報をログに出さない
- パスワードは値も文字数もログに出さない。認証に失敗したときも「
mqtt_passと当該アカウントを確認」という文面だけを出す - NVS から読んだ平文は、渡した直後に
memsetでゼロクリアする - Kconfig(=ビルド時に決まる値)に入れたのはブローカー URI・送信間隔・keepalive だけ。認証情報は NVS のみ
- 認証情報の実ファイルと、シリアルキャプチャのログは
.gitignoreの内側に置く - プロビジョニングのスクリプトが作る平文の一時ファイルは
%TEMP%に置き、finallyで必ず削除する
ここまでやっても、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/homesensor の firmware/ ディレクトリです。
基板が値を出して、60秒ごとに MQTT で飛ぶようになりました。次回はその受け手を作ります。Mosquitto を匿名禁止で立てて、1デバイス1アカウント+ACL でなりすましを構造的に止める設計にし、Telegraf で InfluxDB へ流し込むところまでです。今回「キーを消す」ことにこだわった理由が、そこで効いてきます。
関連記事
- KiCadで作るESP32-S3センサー基板|設計から手はんだ実装まで【自宅センサー基盤 #1】 — この基板の設計・製造編
- 自宅MQTTサーバーをDockerで構築|ACLでなりすましを遮断【自宅センサー基盤 #3】 — 次回。Mosquitto・Telegraf・InfluxDB で受け皿を作る
- Grafanaで作る自宅環境ダッシュボード|8パネルの設計【自宅センサー基盤 #4】 — 最終回。今回送ったデータが部屋のグラフになる
- EIM(ESP-IDF Installation Manager)の使い方 — 複数バージョンを共存させる。今回のビルド環境
- VS Codeで始めるESP-IDF環境構築ガイド — これから ESP-IDF を触るなら
- ESP-IDF 6.0 移行ガイド — レガシードライバと新ドライバの全体像
- ESP-IDF 6.0×ESP32-S3でWi-Fi接続とHTTP POST — Component Registry と
idf_component.yml - I2C・SPI・UART の違い — 3センサを1バスにまとめた I2C の基礎