SCD40 を Raspberry Pi / Pico で使う — CO₂・温度・湿度の I2C 読み出しと動作確認 [#DE-0002-0003]

Sensirion の SCD40 を Raspberry Pi(Linux / Python)と Raspberry Pi Pico(MicroPython)で動かし、CO₂・温度・相対湿度を I2C で読み出す手順と、実機で確認した動作ログをまとめます。

この記事で持ち帰れること:

  • SCD40 を動かすために最低限必要な I2C コマンドと手順
  • 起動直後の温度変動や、締め切り室内の CO₂ ベースライン値など、実測データ
  • 再現性の高い Python / MicroPython 実装(コピペで動く)

簡易育苗器プロジェクト(ver1.1 製作記)の温湿度センサ候補として評価した記録です。育苗器への組み込みを想定している方にも参考になると思います。

まず動かす — 最小限の起動シーケンス

SCD40 は 周期測定モード(start_periodic_measurement で動かすのが基本です。電源投入後の流れは次のとおりです。

  1. 電源立ち上がり後、最大 30 ms 待機
  2. stop_periodic_measurement0x3F86 を送る(前回クラッシュ対策)
  3. 500 ms 待機
  4. start_periodic_measurement0x21B1 を送る
  5. 5 秒 待機(初回サンプルが出るまでの最低待機時間)
  6. read_measurement0xEC05 を 5 秒おきに繰り返す

「stop → 500 ms → start」を毎回行う理由は、前回の実行が途中でクラッシュするとセンサが周期測定モードのまま残るためです。この状態で再度 start を送ると OSError: [Errno 121] Remote I/O error になります。常に stop から始めておくと、状態に関わらず安定して起動できます。

必要なもの

  • SCD40(Sensirion SCD4x シリーズ
  • Raspberry Pi(Linux)または Raspberry Pi Pico(MicroPython)
  • I2C 接続(SDA / SCL + 3.3V / GND)
  • Raspberry Pi の場合: smbus2pip install smbus2

I2C 接続の基本

項目
7 bit I2C アドレス 0x62
SCL 最大速度 400 kHz(ファストモード)
電源電圧 3.3V

SCD40 は I2C でのみ通信します。シングルショット測定は SCD40 では非対応(SCD41 / SCD43 の機能)のため、周期測定モード一択です。

Raspberry Pi(Python)実装

smbus2 を使った最小実装です。i2c_rdwr で書き込みと読み出しをまとめて行うのがポイントです。

import time
from smbus2 import SMBus, i2c_msg

SCD4X_I2C_ADDR = 0x62

CMD_STOP_PERIODIC  = bytes([0x3F, 0x86])
CMD_START_PERIODIC = bytes([0x21, 0xB1])
CMD_READ_MEASUREMENT = bytes([0xEC, 0x05])


def scd4x_prepare_for_start(bus: SMBus) -> None:
    """前回クラッシュ時の復帰: stop → 500 ms してから start できる状態にする。"""
    try:
        bus.i2c_rdwr(i2c_msg.write(SCD4X_I2C_ADDR, CMD_STOP_PERIODIC))
        time.sleep(0.5)
    except OSError:
        time.sleep(0.05)


def crc8_sensirion(data: bytes) -> int:
    """データシート Table 40 の CRC-8(2 バイト分)。"""
    crc = 0xFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x80:
                crc = (crc << 1) ^ 0x31
            else:
                crc <<= 1
            crc &= 0xFF
    return crc


def words_from_measurement(buf: bytes) -> tuple[int, int, int]:
    """9 バイトを word[0..2] に分解し、CRC を検証。"""
    if len(buf) != 9:
        raise ValueError("need 9 bytes")
    w0 = (buf[0] << 8) | buf[1]
    if crc8_sensirion(bytes([buf[0], buf[1]])) != buf[2]:
        raise ValueError("CRC error CO2")
    w1 = (buf[3] << 8) | buf[4]
    if crc8_sensirion(bytes([buf[3], buf[4]])) != buf[5]:
        raise ValueError("CRC error T")
    w2 = (buf[6] << 8) | buf[7]
    if crc8_sensirion(bytes([buf[6], buf[7]])) != buf[8]:
        raise ValueError("CRC error RH")
    return w0, w1, w2


def convert_measurement(w0: int, w1: int, w2: int) -> tuple[float, float, float]:
    """CO2 [ppm], T [°C], RH [%](データシート Table 11)。"""
    co2 = float(w0)
    t   = -45.0 + 175.0 * w1 / (2**16 - 1)
    rh  = 100.0 * w2  / (2**16 - 1)
    return co2, t, rh


def read_measurement_raw(bus: SMBus) -> bytes:
    """コマンド送信 → 1 ms 待機 → 9 バイト読取。"""
    bus.i2c_rdwr(i2c_msg.write(SCD4X_I2C_ADDR, CMD_READ_MEASUREMENT))
    time.sleep(0.001)
    rd = i2c_msg.read(SCD4X_I2C_ADDR, 9)
    bus.i2c_rdwr(rd)
    return bytes(rd)


def main():
    with SMBus(1) as bus:
        time.sleep(0.03)          # 電源立ち上がり後最大 30 ms
        scd4x_prepare_for_start(bus)
        bus.i2c_rdwr(i2c_msg.write(SCD4X_I2C_ADDR, CMD_START_PERIODIC))
        time.sleep(5.0)           # 最初のサンプルまで 5 s

        t0 = time.time()
        while True:
            raw = read_measurement_raw(bus)
            w0, w1, w2 = words_from_measurement(raw)
            co2, t, rh = convert_measurement(w0, w1, w2)
            print(f"time: {time.time()-t0:.2f}s | CO2={co2:.0f} ppm, T={t:.2f} °C, RH={rh:.1f} %")
            time.sleep(5.0)


if __name__ == "__main__":
    main()

i2c_msg.read(...) の戻り値は bytes(rd) に変換してから使うと、CRC や word 分解の際に扱いやすいです。

Raspberry Pi Pico(MicroPython)実装

import time
from machine import I2C, Pin

ADDR      = 0x62
_CMD_STOP  = bytes([0x3F, 0x86])
CMD_START  = bytes([0x21, 0xB1])
_CMD_READ  = bytes([0xEC, 0x05])

# I2C0: SCL=GP1, SDA=GP0 の例(ボードのピンアウトに合わせて変更)
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400_000)


def crc8(data):
    crc = 0xFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x80:
                crc = ((crc << 1) ^ 0x31) & 0xFF
            else:
                crc = (crc << 1) & 0xFF
    return crc


def read_measurement():
    i2c.writeto(ADDR, _CMD_READ)
    time.sleep_ms(1)
    buf = i2c.readfrom(ADDR, 9)
    w0 = (buf[0] << 8) | buf[1]
    w1 = (buf[3] << 8) | buf[4]
    w2 = (buf[6] << 8) | buf[7]
    if crc8(bytes([buf[0], buf[1]])) != buf[2]: raise OSError("CRC CO2")
    if crc8(bytes([buf[3], buf[4]])) != buf[5]: raise OSError("CRC T")
    if crc8(bytes([buf[6], buf[7]])) != buf[8]: raise OSError("CRC RH")
    co2 = w0
    t   = -45.0 + 175.0 * w1 / 65535
    rh  = 100.0 * w2  / 65535
    return co2, t, rh


# 起動シーケンス
time.sleep_ms(30)
try:
    i2c.writeto(ADDR, _CMD_STOP)
    time.sleep_ms(500)
except OSError:
    pass
i2c.writeto(ADDR, CMD_START)
time.sleep_ms(5000)

while True:
    co2, t, rh = read_measurement()
    print("CO2:", co2, "ppm  T:", t, "C  RH:", rh, "%")
    time.sleep_ms(5000)

動作結果

起動直後の温度変動

起動直後は自己発熱の影響でチップ温度が高く、センサが読む温度もやや高めに出ます。実測では、起動から約 4〜5 分で温度が安定しました。

以下は起動直後からの抜粋(5 秒間隔、屋内 3 月下旬・埼玉、締め切り環境):

time:   0.01s | CO2=1770 ppm, T=25.24 °C, RH=52.0 %
time:   5.01s | CO2=1775 ppm, T=24.83 °C, RH=53.0 %
time:  30.04s | CO2=1777 ppm, T=23.68 °C, RH=56.4 %
time:  60.07s | CO2=1812 ppm, T=22.76 °C, RH=59.5 %
time: 120.13s | CO2=1835 ppm, T=21.76 °C, RH=62.3 %
time: 240.25s | CO2=1778 ppm, T=21.30 °C, RH=63.7 %

温度は 25.24 °C → 21.3 °C 前後まで約 4 分(240 s)かけて低下し、その後はほぼ安定しました。

温度の妥当性(参考温度計との比較)

start から約 2000 s 後に、手元の温度計 3 台と比較しました(精度はいずれも未校正)。

機器 温度 湿度
SCD40 21.36 °C 64.2 %
温度計1(ダイソー アナログ時計付属) 22.5 °C 62 %
温度計2(デジタル時計付属) 21.4 °C
温度計3(アナログ温度計) 20.0 °C 68 %

参考温度計のばらつき(20.0〜22.5 °C)の中で、SCD40 の値は矛盾しない範囲に収まっていました。データシート上の精度は 15〜35 °C の範囲で ±0.8 °C(目安)です。

また、240 s 付近と 2000 s 付近の温度はほぼ同じだったため、温度計として使うなら start から 4〜5 分以降の値で十分そうです。温度オフセット補正(set_temperature_offset)は、単体での動作確認レベルでは不要でした。

室内の CO₂ ベースライン

測定時は 3 月下旬・埼玉の締め切り一軒家 2 階(花粉対策で窓を閉めた環境)。ベースラインは 約 1700〜1800 ppm 前後で推移しました。換気なしの室内としては妥当な値です。

息を吹きかけたときの応答確認(約 10 秒)

センサの応答確認として、SCD40 に向けて約 10 秒間息を吹きかけたところ、CO₂ が 約 4000 ppm 付近まで上昇しました。

time: 360.37s | CO2=1773 ppm  ← 吹きかけ前
time: 365.38s | CO2=1911 ppm
time: 375.39s | CO2=2344 ppm
time: 415.43s | CO2=3836 ppm
time: 425.44s | CO2=4064 ppm  ← ピーク付近
time: 430.44s | CO2=4064 ppm

番外:呼気の CO₂ 濃度を 5 分間測定

センサがどこまで追えるかを見るため、約 45 mm の距離から 5 分間息を吹きかけ続ける実験を行いました。

実験条件

  • 口と SCD40 の距離: 約 45 mm
  • 20 秒に 1 回、首を横に向けて吸気(吸気がセンサに当たらないよう配慮)
  • 0〜150 s: 強めに「ふぅー」と吹く
  • 150 s 以降: 弱めに「はぁー」と吹く

代表点

タイミング CO₂ T RH
開始(0 s 相当) 2161 ppm 21.22 °C 65.0 %
吹き方切替(150 s 相当) 5638 ppm 22.57 °C 72.7 %
終了(300 s 相当) 16145 ppm 25.20 °C 91.4 %

考察

  • 強めの「ふぅー」では 約 5000 ppm 前後で頭打ちになりやすい。勢いでセンサ周辺の高濃度空気を吹き飛ばしているためと考えられます。
  • 弱めの「はぁー」に切り替えると 約 16,000 ppm まで到達しました。流速が低いほど高濃度空気がセンサ付近に滞留しやすいためと考えられます。

センサとして十分な応答が確認でき、流速による挙動の違いも面白い結果でした。

仕様の要点

測定値の変換式(データシート Table 11)

read_measurement0xEC05)を送ると 9 バイト返ってきます。各 word(2 バイト)の後に CRC が 1 バイト付きます。

物理量 変換式
CO₂ [ppm] word[0](そのまま)
温度 [°C] −45 + 175 × word[1] / (2¹⁶ − 1)
相対湿度 [%] 100 × word[2] / (2¹⁶ − 1)

CRC-8(データシート §3.12 Table 40)

項目
多項式 0x31(x⁸+x⁵+x⁴+1)
初期値 0xFF
対象 直前の 2 データバイト

読み取り時の CRC 検証は任意ですが、実装に含めておくと通信エラーを早期に検知できます。

まとめ

  • SCD40 は I2C アドレス 0x62、周期測定モード(5 秒更新)で動かすのが基本です。
  • 起動シーケンスは「stop → 500 ms → start」にすると、前回クラッシュ後の Errno 121 を回避できます。
  • 起動直後は温度が高めに出るため、4〜5 分待ってから値を使うのが無難です(用途によっては 240 s 以降でも十分)。
  • 締め切り室内のベースラインは約 1700〜1800 ppm でした。換気状態の確認センサとしても十分使えそうです。

次のステップとして、育苗器への組み込みと長時間ロギングを試す予定です。