技術約9分で読めます

StackChanにCO2センサーをつないで部屋の空気情報を音声チャットに反映させた

いけさん目次

前回は、StackChanの頭上タッチが触っていないのに反応し続けたので、話しかけをKeyユニットに戻した。
同じ日に、音声チャットサーバーのAPIが更新された。
センサーの値を音声とは別に送っておくと、返答のときにLLMが参照できる仕組みができたので、CO2センサーのSCD41をつないで、部屋の空気のことを聞いたら答えられるか試してみた。

検証環境

項目使用した環境
実機M5Stack CoreS3(ESP32-S3)、StackChan Body
話しかけのスイッチKeyユニット(U144)、頭上のPort C(G18)
CO2センサーSCD41(CO2・温度・湿度)、本体横のPort A(G2/G1、I2C)
ファームESP-IDF 5.5.4、arduino-esp32 3.3.10、M5Unified 0.2.17、StackChan-BSP 1.1.0
音声サーバーへの経路Tailscaleで直結(中継なし)
音声サーバー別PC。音声認識 Qwen3-ASR-0.6B、LLM Qwen3.8-Max(ModelScope)、TTS音声合成
比較に使った温湿度計SwitchBotのCO2センサー

サーバー側で増えたもの

サーバーは GET /help で仕様書をMarkdownで返す。
2026年9月27日版で、端末側に関係する主な変更内容は次の表のとおり。

項目内容端末側の対応
POST /context位置やセンサー値を好きなときに送る。本文の形式は自由で、サーバーは解釈せずにLLMのシステムプロンプトへそのまま入れる新しく実装
値の寿命JSONはキーごとに上書き。受け取ってから30分でプロンプトから消える5分おきに送り直す
/job の memoriesその返答で思い出した長期記憶(0〜3件)。デバッグ用シリアルに出すだけ
LLMQwen3.8-Maxに変更。応答の形式は同じ変更なし
直結端末は中継を通さずサーバーを直接呼ぶ前々回のTailscale直結のまま

日時はサーバーPCの時計が毎回プロンプトに追加されるので、端末からは送っていない。
LLMへの指示は「関係する話題のときだけ使い、聞かれていないのに数値を言わない」と設定されている。

入力を受け付けなかった原因

作業を始めたとき、StackChanはKeyユニットを押しても反応しなかった。
前回の最後に、公式ファームのタッチ処理だけを動かす確認用のファームを書き込んで、一晩ログを取っていた。
それが入ったままで、確認用なので話しかけの処理がない。

音声チャットのファームを書き戻し、書き込み前に退避しておいた設定領域(Tailscaleの鍵と音量)を戻した。
起動から音声サーバーへの直結まで、この日は22〜30秒だった。

SCD41の値をPOST /contextで送る

SCD41のドライバは、CO2モニターとして使っていたときのコードをそのまま移した。
I2Cでコマンドを直接送る短いもので、頭上タッチやスピーカーが使う本体内部のI2Cとは別のバスになる。

処理内容
読み取り5秒周期の測定。見つからなければ30秒おきに探し直す
送るとき初回、5分おき、CO2が100ppm以上か温度が1℃以上変わったとき
送らないとき録音中・返答待ち・再生中(HTTPで処理を止めないため)
送る本文{"room":{"co2_ppm":982,"temp":"27.4 C","humidity_pct":71}}

最初の送信は109msでHTTP 200が返り、PCから GET /context へリクエストを送ると同じ値が返ってきた。

{"values":{"room":{"value":{"co2_ppm":982,"temp":"27.4 C","humidity_pct":71},"age_s":24}},"max_age_s":1800}

話しかけて確かめる

Keyユニットを押すとLEDが赤くなり、話し終えて声が1.5秒途切れると送信される。
時間は、送信してからの経過で測った。

話しかけた内容直前に送っていた値返答1文目の再生開始
今日暑いね。1079ppm、26.0℃、87%ほんと蒸し暑いよね、部屋の中もじっとりしててしんどい。水分ちゃんととって涼しくしてね。13.7秒
部屋の温度は何度。1354ppm、26.0℃、88%今ちょうど26度みたい。でも湿度がかなり高いから、体感的にはもっと暑く感じるよね。7.6秒
部屋の二酸化炭素濃度ってどれくらい。1454ppm、26.0℃、88%今は1454ppmくらいで、ちょっと高めだね。窓を開けて換気したほうがすっきりすると思うよ。10.9秒
こんにちは。1210ppm前後こんにちは、今日もよろしくね。7.0秒

温度とCO2は、送った値をそのまま答えた。
CO2の質問は、押す3秒前に送った1454ppmがそのまま返ってきている。
「こんにちは」には数値を出さず、「今日暑いね」には数値を言わずに「部屋の中もじっとり」と湿度の値を使ったような返し方をした(どの値を使ったかはログからは分からない)。

1回目だけ聞き取りに9.9秒かかり、ほかは3.9〜7.3秒だった。
サーバーの仕様書に、アイドル(待機状態)が長かった直後の1回目は遅れることがあると書かれており、その仕様に該当した可能性がある。

会話のあとは、長期記憶は1件もできなかった。
今回の4回の会話には「あなたは〜」の形にできる本人の話が入っていないので、抜き出すものがなかったのだと思う。

撫で判定の誤反応の調査

頭上タッチは、叩いても何もしないまま、撫でたときだけ喜ぶ顔を2.5秒出すようにした。
音もサーボも動かさない。
撫での判定は前回移した公式ファームと同じで、3つのパッドの強さから重み付きの位置(-100〜100)を出し、触れ始めから40を超えて動いたら撫でにする。

起動してから8分は、誰も触らない状態で接触0回だった。
ところが起動から約560秒で急に騒がしくなり、触っていないのに顔が喜び出した。

時間帯接触撫での判定
17時17分台28回10回
17時18分台0回0回
17時19分台44回12回

このときの接触は、強さ(0〜3)の最大がほぼ1で、3つのパッドにばらばらに一瞬ずつ出ていた。
前のパッドと後ろのパッドが交互に1になると、重み付きの位置が-100と100の間を跳ねて、そのまま撫でになる。

そこで、接触中にどれかのパッドが強さ2以上にならないと撫でにしない条件を足した。
公式ファームにはない条件で、書き込み直した直後の6分は接触184回に対して撫での判定0回だった。

それでも止まらなかった。
起動から時間がたつほど、誤反応の強さが上がっていった。

時間帯強さ1強さ2強さ3撫での判定
17時20分台417回5回1回0回
17時30分台547回76回1回19回
17時40分台703回366回7回92回
17時50分台743回711回55回182回

回数は、強さの読み取りが変わったときのログの行数で数えた。
強さ2の誤反応は 1 1 1→2 1 1→2 1 2 のように3つのパッドが同時に反応していて、指が1つのパッドに乗ったときの形ではなかった。
条件を強さ3に上げても、10分で約50回出て止まらなかった。

「端のパッドが強くて反対の端が0の状態から、反対の端だけの状態へ移る」という形を条件にする案も、ログに当てはめてみた。
誤判定はおよそ10分の1になったが、0にはならず、本物の撫でが取れるかも確かめていない。

見分けられないので、撫でで顔を変えるのはやめて、判定はログに残すだけにした。
シリアルから pet 1 を送れば、喜ぶ顔を出すように戻せるようにしてある。
CO2センサーを足した状態での結果だが、前回はSCD41なしでも同じ傾向があったので、センサーの影響ではなさそうだ。

返答の感情で首を動かす

頭上タッチを入力に使わないと、StackChan Bodyで動くのは首の2軸サーボだけになる。
サーバーは返答ごとに表情(emotion)を返しているので、1文目の再生を始めるときに、顔の切り替えと一緒に首も動かすことにした。

軸範囲原点
左右(yaw)±128度正面
上下(pitch)0〜90度一番下。原点より下は向けない

StackChan-BSPの首の動きは、ばねのアニメーションで目標へ近づく。
専用のタスクでサーボを動かすので、指示を出したらすぐ戻ってきて、会話の処理は止まらない。
下は向けないので、sorrow は「うつむく」ではなく「ゆっくり横を向いて止まる」にした。

emotion首の動き
smile小さく1回うなずく
joy上を向いて2回弾む
anger小刻みに左右へ振る
sorrowゆっくり横を向いて止まる
fun少し上を向いて左右にゆらゆら
(考え中)フィラー(応答待ちのつなぎ言葉)の間、少し上を向いて首をかしげる

会話が終わったら正面に戻し、録音を始める前は首が止まるのを待つ。
サーボの音は思ったより大きいが、再生音と重なる分は音量を上げて対処した。

最初は1つの動きが終わってから次の動きを出していて、うなずきがかなり遅かった。
速さの指定を上限の1000まで上げても、ばねの硬さに上限があり、目標に近づくほど遅くなって、止まったと判定されるまでに時間がかかった。
目標まで数度に近づいたら次の動作へ切り替えるようにすると、うなずき1回が0.2〜0.4秒になった。

ただ、折り返す距離を2度にすると、funの2往復目で2.6秒止まった。
首は目標の1〜2度手前で止まることがあり、左へ−25度を指示して−22.8度で止まったまま、着いた判定にならずに時間切れを待っていた。
距離を4度にすると時間切れはなくなり、joyは全体で約1.1秒、funは約1.6秒で終わった。

会話では、「お腹が空いて力が出ない。」がsorrowになり、「それはお腹すいてしんどいよね、早く何か食べて元気出してね。」の1文目と一緒に、0.9秒かけて横を向いた。
見ていた側の感想は「かなしそう」だった。

SCD41の値がSwitchBotと合わない

話しかけている途中で、SwitchBotの値とずれていた。

SCD41SwitchBot
CO21216ppm733ppm
温度26.6℃27.8℃
湿度84.9%65%

SCD41をつなぐのはしばらくぶりだった。
SCD41の自動校正は、何日も続けて動かし、その間に外の空気(約400ppm)に触れることで基準を合わせる。
強制校正も、本来は外に出すなどして、濃度がはっきりした空気の中でかける。
今日つないだばかりで、どちらもやっていないので、CO2はずれていて当然だった。

温度は、SCD41に保存されている温度オフセット(センサー自身の発熱を差し引く量)で決まる。
読み出すと6.02℃で、出荷時の4℃より大きかった。
7月にCO2モニター本体のケースに入れて、SwitchBotに合わせたときの値が残っていた。

項目7月(CO2モニターのケース)今回(StackChan)
温度オフセット4.00→6.02℃6.02→4.34→2.41℃
湿度表示側で0.803を掛けていた表示側で0.880を掛ける
CO2の強制校正1172ppmを基準に+313ppm763ppmを基準に−382ppm

SCD41は測定を始め直すたびに、温度が31℃前後から下がっていき、下がりきるまで約10分かかった。
温度が安定したところで実温度を27.8℃としてオフセットを計算し直すと4.34℃になり、その後は28.0〜28.1℃で推移した。
湿度は温度オフセットを変えても79%のままで、SwitchBotの65%には届かなかった。

センサー本体に保存すると、CO2モニターに戻したときにまた合わなくなるので、校正値は音声チャットのファームの設定領域に保存し、起動時にセンサーへ設定するようにした。
測定開始から10分は、値が変動している温度と湿度を送らずに、CO2だけを送るようにした。

ところが、この設定で起動し直すと、同じオフセット4.34℃で温度は25.9℃で止まり、そこから25.3℃まで下がった。
19時22分のSwitchBotは27.3℃、67%、763ppmで、SCD41は25.4℃、85.4%、1147ppmだった。
外気は22℃で、SCD41は本体の横に出ているので、外から入る空気を先に受けているのかもしれない。
ただ、そのあと30分以上、差は1.9℃でほぼ変わらなかった。
18時台に4.34℃で合っていた理由は分かっていない。

CO2は、センサーが20分以上動いた状態で、SwitchBotの763ppmを基準に強制校正をかけた。
補正は−382ppmで、校正後は739ppmになった。

温度は、差の変動が止まっていたので実温度27.3℃で合わせ直し、オフセットは2.41℃になった。
10分待って27.4〜27.5℃で安定してから、湿度の生の値76.2%をSwitchBotの67%に合わせる係数0.880を入れた。

SCD41(19時45分、校正後)SwitchBot
CO2766ppm762ppm
温度27.5℃27.3℃
湿度67.0%67%

これは屋内でSwitchBotの表示に数値を合わせた暫定の調整で、SCD41本来の校正手順(外気暴露による自動校正や既知濃度下での強制校正)ではない。
外気で強制校正をかけるか日数をかけて自動校正を効かせないと、CO2は再びずれるはずだ。
温度オフセットも同日中に4.34℃と2.41℃の開きが出ており、使用環境や筐体温度に合わせた追い込みがまだ要る。