CO2濃度は集中力に影響する——と聞いて、部屋のCO2がいくつなのか知りたくなり、SwitchBotのCO2センサー(温湿度計)を買いました。本体の画面を見れば、いまの値はわかります。ただ、本当に知りたいのは「いま何ppmか」だけではなく、1日の中でどう上がり下がりしているか、つまりグラフです。それには値を機械的に取り出して貯め続ける必要があります。

正攻法は公式のクラウドAPI。でも、すぐそこの棚にある機器の値を、わざわざインターネットの向こうまで取りにいくのは遠回りです。実はSwitchBotのセンサー類は、BLE(Bluetooth Low Energy)で測定値を常時ばらまいています。これを直接受信すれば、ハブもクラウドもAPIトークンも要りません。最初のプロトタイプはPythonで56行でした。この記事はその顛末です。

手元にあるのはSwitchBotのCO2センサー(温湿度計、海外名 Meter Pro CO2。後述のスキャン結果には MeterProCO2 という機種名で出てきます)が2台とCO2なしの温湿度計1台。どれもWi-Fiには直接いない、BLEでしゃべる機器です。

読む経路は3つあった

SwitchBotのセンサー値を機械的に取る経路は、調べると3つあります。

経路1: Hub 2のMatterブリッジ。 SwitchBotのハブ「Hub 2」は、配下の機器をMatter機器として見せてくれる機能を持っています。これが使えれば話が早い——のですが、調べるとCO2の値だけが出てきません。温度と湿度はMatterに橋渡しされるのに、肝心のCO2はブリッジが対応していない。温湿度止まりでした。

経路2: クラウドAPI。 公式のSwitchBot API(v1.1)です。アプリからトークンを発行して、署名付きのHTTPリクエストを投げると値が返ってきます。動くことは動く。ただし毎回クラウドを往復しますし、レート制限が1アカウントあたり1日10,000回。5分間隔で3台をポーリングし続ける用途だと、上限の1割弱を定常的に食いつぶします。家の中の空気の話なのに、経路だけが遠回りです。

経路3: BLEアドバタイズの直接受信。 BLE機器は「アドバタイズ」というパケットを周囲に定期的にばらまいています。自分はここにいるよ、という自己紹介の電波です。SwitchBotのセンサー類は、この自己紹介に測定値そのものを載せています。つまり受信するだけでいい。接続すら不要で、聞き耳を立てるだけ(パッシブスキャンと言います)。ハブ不要、クラウド不要、レート制限なし。

経路3の一択でした。うちのラズパイ(3B+)にはBluetoothが載っているので、追加ハードもゼロです。

まず、届くかを確かめる

いきなり作り込む前に、そもそもラズパイの位置でアドバタイズが受信できるのかを確かめました。BLEの電波は壁に弱いので、ここがだめなら話が終わります。

15秒のパッシブスキャンを3回。結果は、温湿度計もCO2センサーも3回とも全部見えました。CO2計のペイロード(電波に載っているデータ本体)も、CO2の値が入っているとされる長さを満たしている。行けそうです。

Python 56行のプロトタイプ

最初のプロトタイプは、BLEライブラリのbleakを使ったPythonスクリプトです。やることは単純で、スキャナを起動して15秒待ち、拾ったアドバタイズを機器ごとに持っておいて、最後にJSONで吐くだけ。骨格はこれだけです。

async def scan(duration: float) -> dict:
    devices = {}

    def cb(device, adv):
        sd = adv.service_data.get(SERVICE_UUID)
        if sd is None:
            return
        md = adv.manufacturer_data.get(COMPANY_ID)
        devices[device.address] = (bytes(sd),
                                   bytes(md) if md is not None else None,
                                   adv.rssi)

    scanner = BleakScanner(detection_callback=cb)
    await scanner.start()
    await asyncio.sleep(duration)
    await scanner.stop()
    return devices

デコード処理を合わせても、スクリプト本体は56行。実行すると、こういうJSONが返ってきます(アドレスは一部伏せています)。

[
  {"device_id":"B0:E9:FE:xx:xx:xx","model":"MeterProCO2","battery_pct":49.0,"temperature_c":25.3,"humidity_pct":59.0,"co2_ppm":651.0,"rssi":-67},
  {"device_id":"B0:E9:FE:xx:xx:xx","model":"MeterProCO2","battery_pct":100.0,"temperature_c":25.6,"humidity_pct":57.0,"co2_ppm":595.0,"rssi":-71},
  {"device_id":"E3:5F:40:xx:xx:xx","model":"Meter","battery_pct":100.0,"temperature_c":27.2,"humidity_pct":53.0,"rssi":-49}
]

15秒のスキャン1回で、3台ぶんの温度・湿度・CO2・電池残量がまとめて取れました。クラウドAPIなら3リクエストのところが、受信0リクエストです。

どのバイトが温度なのか

アドバタイズに値が「載っている」と言っても、届くのは生のバイト列です。どこが温度でどこがCO2なのかは解読が必要で、ここはAI(Claude)に任せて、先人の資産であるpySwitchbot(Home Assistantが使っているパーサ群)を参照してもらいました。

SwitchBotのアドバタイズには2種類のデータが載っています。

  • 製造者データ: メーカーが自由に使える領域。SwitchBotの会社ID 0x0969 で識別します。測定値の本体はこちら
  • サービスデータ: UUID 0xfd3d に紐づく領域。先頭1バイトが機種タグで、5 ならCO2センサー、T なら温湿度計、という具合に機種を見分けられます

たとえば温度と湿度は、製造者データの中の連続する3バイトにこう詰まっています。

  • 1バイト目の下位4ビット: 温度の小数第1位
  • 2バイト目: 最上位ビットが符号(立っていればプラス)、残り7ビットが温度の整数部
  • 3バイト目: 最上位ビットは華氏表示設定のフラグ(読み飛ばす)、残り7ビットが湿度%

0x03 0x9b 0x3e なら27.3℃・62%です。1バイトも無駄にしない詰め方です。CO2はさらに後ろの2バイトにppm値がそのまま入っています。センサーの仕様上の測定範囲は400〜9999 ppmなので、上限を超える値は一時的なノイズとみなして捨てるようにしました。

ハマりどころは電波ではなくMACだった

事前に心配していた電波の到達性は、まったく問題になりませんでした。代わりにハマったのは「どのMACアドレスが、どの部屋のどの機器なのか」です。

BLEアドバタイズにはMACアドレス(機器固有の識別子)しか入っておらず、アプリで付けた「書斎」みたいな名前は載っていません。そしてスキャンすると、CO2センサーらしき機器が2台見える。どっちがどっちか分かりません。

確定させた方法は原始的で、スマホアプリに表示されている現在値と、同時刻のスキャン結果を突き合わせました。温度・湿度・CO2の3つの値が同時に一致すれば、それが本機です。

ここでひとつ、罠がありました。アプリでデバイスの画面を開いている間、その機器はアドバタイズを止めます。 照合しようとアプリを開くと、その瞬間スキャンから消える。見ようとすると隠れる、かくれんぼみたいな挙動です。アプリで値を確認したら画面を閉じてからスキャンする、という手順に落ち着きました。

もうひとつ、誤認もありました。といっても僕ではなく、解析を任せていたAIのほうです。2台目のCO2センサーを、AIは当初「近隣の家の機器が電波で紛れ込んでいる」と判定して、収集対象から外していました。BLEは電波の届く範囲のアドバタイズを全部拾うので、住宅街では実際に隣家の機器が見えることがあり、推論として筋は通っています。が、実物はふつうに自宅のリビングに置いてある自分の機器でした。家に何台あるかまではAIには見えないので、機器の台数のような物理側の事実は最初にこちらから渡しておくべきでした。知らない機器を無条件に取り込まない設計(未登録の機器は隔離する)にしていたのは、結果的に正しかったのですが。

細かいところでは、権限まわりも一手間ありました。BLEスキャンは特権操作なので、収集を担うユーザーを bluetooth グループに入れる(またはバイナリに setcap で権限を付ける)必要があります。

5分間隔で、家のCO2が貯まりはじめた

プロトタイプを、家のあらゆるデータ(発電量・電力消費・水道・ガス…)を1か所に集めている収集基盤に組み込んで、「15秒スキャンを5分間隔」で回すようにしました。1回のスキャンで3台ぶんが取れるので、これで書斎・リビング・サーバールームの温湿度と、書斎・リビングのCO2・各機器の電池残量が、時刻付きのJSONとして淡々と積もっていきます。

データはまだ貯まりはじめたばかりで、曲線を眺めるところまでは行っていません。それでも、最初のスキャンの時点で書斎は651 ppm、リビングは595 ppmと、部屋ごとに数字が違うことは分かっています。アプリで一瞬見て消えていた値が、これからは比べられる記録として積もっていく。一日のなかでこの数字がどう動くのか、貯まったら眺めるのが楽しみです。

その後: 56行のPythonはRust製CLIになった

56行のプロトタイプは役目を果たしたので、その後ちゃんとした専用CLIに置き換えました。swbという名前で、僕がつくってGitHubで公開しているRust製のツールです。ECHONET Lite用の enl、Matter用の mat と同じ思想で、SwitchBotというプロトコル1つだけを担当します。

RustにしたのはPythonの延長ではなく、既存の enl / mat に合わせたかったからです。5分間隔で回り続ける常駐の収集役なので、venvやインタプリタのバージョン管理を持ち込まず、単一バイナリを置くだけで動く方が運用が楽になります。プロトタイプ止まりならPythonのままでよかったのですが、家のインフラの一部として長く動かす以上、他のツール群と同じ土台に合わせておく方が扱いやすいと判断しました。

設計上の割り切りとして、センサーの読み取りはBLE、機器の操作はクラウドAPI、と経路を分けました。読み取りは今回書いたとおりBLEで完結しますが、ボットやプラグを「動かす」方向の通信はアドバタイズでは送れないためです。読むだけなら、トークンの設定すら不要で動きます。

# 15秒スキャンして、機器ごとに1行のJSONを出す
swb scan --once --timeout 15

家のCO2が貯まる仕組みはできたので、次はこのデータで何をするかです。換気のタイミングを自動化に組み込むか、その前に貯めたデータの分析基盤の話か。このあたりを次の記事で書くつもりです。