ぺんぎんらぼ

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です。たまに絵を描きます。

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です

ヘラクレスオオカブト生育環境を最適化する(5)~定期実行とSlack通知~

今回は、前回読み取れるようになった温度と湿度を、1 時間に 1 回 Slack へ通知するところまで進めます。

前回までは main.py の中に、センサー読み取り処理がまとまっていました。今回はそこに Slack 通知も入ってくるので、ファイルを分けました。

今回の構成

今回の Python ファイルは、以下の 3 つに分けました。

src/
  main.py          # 全体の流れ
  sensor.py        # ENV III / SHT30 から温度・湿度を読む
  slack_notify.py  # Wi-Fi 接続と Slack 通知

さらに、Wi-Fi パスワードや Slack の Webhook URL はコードに直接書かず、config.py に分けます。

src/
  config.py        # 自分の環境用の秘密情報

config.py には、以下のような値を書きます。

WIFI_SSID = "your-wifi-ssid"
WIFI_PASSWORD = "your-wifi-password"
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/XXX/YYY/ZZZ"

Wi-Fi パスワードや Slack の Webhook URL は公開してはいけない情報なので、.gitignore に入れて Git 管理しないようにしています。

main.py

main.py は、全体の流れだけを担当します。

from machine import Pin
import time

from sensor import read_sht30, scan_i2c
from slack_notify import build_slack_message, connect_wifi, post_slack_message

BTN_PIN = 39
MEASURE_INTERVAL_MS = 60 * 60 * 1000

btn = Pin(BTN_PIN, Pin.IN, Pin.PULL_UP)


def main():
    print("atom lite ready. reading ENV III and posting to Slack.")
    connect_wifi()
    scan_i2c()

    while True:
        try:
            temperature, humidity = read_sht30()
            print("temperature: {:.2f} C, humidity: {:.2f} %".format(temperature, humidity))
        except OSError as error:
            print("sensor error:", error)
        else:
            try:
                post_slack_message(build_slack_message(temperature, humidity))
                print("Slack message sent.")
            except Exception as error:
                print("notification error:", error)

        if btn.value() == 0:
            scan_i2c()
            time.sleep_ms(300)

        time.sleep_ms(MEASURE_INTERVAL_MS)


if __name__ == "__main__":
    main()

ファイルを分ける理由

最初は全部 main.py に書いても動きます。

ただ、処理が増えてくると、1 つのファイルがどんどん長くなります。

今回のプログラムには、大きく 3 種類の処理があります。

  • センサーから温度と湿度を読む
  • Wi-Fi に接続して Slack に送る
  • 1 時間ごとに実行する流れを管理する

これらを分けると、main.py がかなり読みやすくなります。

main.py だけを見ると、

connect_wifi()
scan_i2c()
temperature, humidity = read_sht30()
post_slack_message(build_slack_message(temperature, humidity))

という感じで、何をしているかが上から読めます。

中身の細かい実装は、sensor.pyslack_notify.py に隠れています。

これは Python に限らず、プログラムを大きくしていくときの大事な考え方です。

import で別ファイルの関数を使う

main.py には、次の行があります。

from sensor import read_sht30, scan_i2c
from slack_notify import build_slack_message, connect_wifi, post_slack_message

これは、別ファイルで定義した関数を読み込んでいます。

sensor.py にある、

def read_sht30():
    ...

を、main.py から使えるようにしているわけです。

Java の import と似ていますが、Python ではファイル名がそのままモジュール名になります。

sensor.py

なら、

from sensor import read_sht30

のように読み込めます。

1 時間に 1 回の設定

MEASURE_INTERVAL_MS = 60 * 60 * 1000

これは、計測間隔をミリ秒で表しています。

MicroPython の time.sleep_ms() は、待ち時間をミリ秒で指定します。

time.sleep_ms(MEASURE_INTERVAL_MS)

そのため、1 時間をミリ秒に直しています。

60 秒 * 60 分 * 1000 ミリ秒

つまり、

60 * 60 * 1000

です。

計算結果は、

3600000

です。

最初から、

MEASURE_INTERVAL_MS = 3600000

と書いても同じです。

ただし、

60 * 60 * 1000

と書いた方が、「1 時間なんだな」と読み取りやすくなります。

try / except / else

今回の main.py では、次のように書いています。

try:
    temperature, humidity = read_sht30()
    print("temperature: {:.2f} C, humidity: {:.2f} %".format(temperature, humidity))
except OSError as error:
    print("sensor error:", error)
else:
    try:
        post_slack_message(build_slack_message(temperature, humidity))
        print("Slack message sent.")
    except Exception as error:
        print("notification error:", error)

try は「失敗するかもしれない処理を試す」場所です。

ここでは、まずセンサーから温度と湿度を読みます。

temperature, humidity = read_sht30()

もしここで OSError が起きたら、except に進みます。

except OSError as error:
    print("sensor error:", error)

OSError は、通信、ファイル、デバイス操作などで失敗したときによく使われる例外です。

SHT30 から読めなかった場合や、I2C 通信に失敗した場合は、ここで「センサーエラー」として表示します。

else は、try の中が成功したときだけ実行されます。

つまり、

else:
    post_slack_message(...)

は、

センサー読み取りに成功したときだけ Slack に通知する

という意味です。

ここで else を使うと、「センサー読み取り」と「Slack 通知」の失敗を分けて扱いやすくなります。

センサーが失敗したときは sensor error

Slack 通知が失敗したときは notification error

どこで失敗したのかがログから分かりやすくなります。

sensor.py

sensor.py は、ENV III の SHT30 から温度と湿度を読む処理を担当します。

from machine import I2C, Pin
import time

I2C_SCL_PIN = 32
I2C_SDA_PIN = 26
SHT30_ADDR = 0x44

i2c = I2C(0, scl=Pin(I2C_SCL_PIN), sda=Pin(I2C_SDA_PIN), freq=100000)

ATOM Lite の Grove 端子に ENV III を接続する場合、MicroPython では以下の指定にします。

I2C_SCL_PIN = 32
I2C_SDA_PIN = 26

SHT30 の I2C アドレスは 0x44 です。

SHT30_ADDR = 0x44

このファイルの中では、前回作った crc8()read_sht30()scan_i2c() をそのまま使っています。

def read_sht30():
    i2c.writeto(SHT30_ADDR, b"\x24\x00")
    time.sleep_ms(20)
    data = i2c.readfrom(SHT30_ADDR, 6)
    ...

read_sht30() は温度と湿度を返します。

return temperature, humidity

そのため、main.py 側では以下のように受け取れます。

temperature, humidity = read_sht30()

Python では、関数が複数の値を返しているように書けます。

実際にはタプルという形で返っています。

return temperature, humidity

は、イメージとしては以下に近いです。

return (temperature, humidity)

受け取る側では、

temperature, humidity = read_sht30()

と書くことで、2 つの変数に分けて受け取れます。

これは Python でよく使う書き方です。

slack_notify.py

slack_notify.py は、Wi-Fi 接続と Slack 通知を担当します。

import network
import socket
import ssl
import time
import ujson

それぞれの役割は、ざっくり以下です。

  • network: Wi-Fi 接続
  • socket: ネットワーク通信
  • ssl: HTTPS 通信
  • time: 待ち時間
  • ujson: JSON 作成

通常の Python では json を使うことが多いですが、MicroPython では軽量版の ujson を使います。

config.py を読み込む

try:
    from config import SLACK_WEBHOOK_URL, WIFI_PASSWORD, WIFI_SSID
except ImportError as error:
    raise RuntimeError("Create config.py from config.example.py before running.") from error

ここでは、config.py から Wi-Fi と Slack の設定を読み込んでいます。

config.py がないと、ImportError が発生します。

今回の config.py は、なくてもよいファイルではありません。

Slack 通知をするには必須です。

そのため、読み込めなかったらその場で RuntimeError にして止めています。

raise RuntimeError("Create config.py from config.example.py before running.") from error

RuntimeError は、Python に最初から用意されている例外です。

「プログラム実行中に、この状態では続けられない問題が起きた」くらいの意味で使えます。

最後の from error は、元の ImportError も原因として残す書き方です。

これにより、エラーを見たときに、

config.py が読めなかった
その結果 RuntimeError にした

というつながりが追いやすくなります。

Wi-Fi に接続する

def connect_wifi():
    if not WIFI_SSID or not WIFI_PASSWORD:
        raise RuntimeError("Wi-Fi settings are missing. Create src/config.py.")

    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)

network.WLAN(network.STA_IF) は、Wi-Fi を子機モードで使う設定です。

家庭の Wi-Fi ルーターに接続する側なので、STA_IF を使います。

wlan.active(True) で Wi-Fi 機能を有効にします。

if not wlan.isconnected():
    print("connecting to Wi-Fi...")
    wlan.connect(WIFI_SSID, WIFI_PASSWORD)

まだ接続されていなければ、config.py の SSID とパスワードを使って接続します。

for _ in range(30):
    if wlan.isconnected():
        break
    time.sleep(1)

ここでは、最大 30 秒待っています。

for _ in range(30): は「30 回繰り返す」という意味です。

_ は、繰り返し回数そのものを使わないときによく使われる変数名です。

1 回ごとに 1 秒待つので、最大 30 秒です。

if not wlan.isconnected():
    raise OSError("Wi-Fi connection failed")

30 秒待ってもつながらなかったら、OSError を発生させます。

Slack の Webhook URL を分解する

def parse_https_url(url):
    prefix = "https://"
    if not url.startswith(prefix):
        raise ValueError("Slack webhook URL must start with https://")

    rest = url[len(prefix):]
    slash_index = rest.find("/")
    if slash_index == -1:
        raise ValueError("Slack webhook URL is invalid")

    host = rest[:slash_index]
    path = rest[slash_index:]
    return host, path

Slack の Webhook URL は、だいたい以下のような形です。

https://hooks.slack.com/services/XXX/YYY/ZZZ

HTTP 通信を自分で組み立てるために、これを hostpath に分けます。

host = hooks.slack.com
path = /services/XXX/YYY/ZZZ

最初に、

if not url.startswith(prefix):

で、URL が https:// から始まっているか確認しています。

違っていたら、

raise ValueError("Slack webhook URL must start with https://")

でエラーにします。

ValueError は、値の内容がおかしいときによく使われる例外です。

次に、

rest = url[len(prefix):]

で、先頭の https:// を取り除いています。

len(prefix) は、"https://" の長さです。

url[len(prefix):] はスライスで、「https:// の次から最後まで」を取り出しています。

その後、

slash_index = rest.find("/")

で、最初の / の位置を探します。

この / より前が host、この / 以降が path です。

host = rest[:slash_index]
path = rest[slash_index:]

ここでもスライスを使っています。

Slack に送るメッセージを作る

def build_slack_message(temperature, humidity):
    return "🪲 ヘラクレスに最適な環境です\n温度: {:.2f} C\n湿度: {:.2f} %".format(
        temperature,
        humidity,
    )

ここでは、Slack に送る文章を作っています。

\n は改行です。

そのため、Slack には以下のように表示されます。

🪲 ヘラクレスに最適な環境です
温度: 28.58 C
湿度: 50.52 %

{:.2f} は、小数点以下 2 桁で表示するための書き方です。

たとえば、

temperature = 28.581234

なら、

28.58

になります。

Slack に HTTP POST する

def post_slack_message(text):
    if not SLACK_WEBHOOK_URL:
        raise RuntimeError("Slack webhook URL is missing. Create src/config.py.")

    host, path = parse_https_url(SLACK_WEBHOOK_URL)
    body = ujson.dumps({"text": text}).encode("utf-8")

Slack の Incoming Webhook には、JSON 形式でメッセージを送ります。

Slack に送る本文は、以下のような形です。

{"text": "🪲 ヘラクレスに最適な環境です\n温度: 28.58 C\n湿度: 50.52 %"}

Python の辞書を JSON 文字列に変換しているのが、次の行です。

ujson.dumps({"text": text})

さらに、

.encode("utf-8")

で、文字列をバイト列に変換しています。

ネットワーク通信では、最終的に文字列ではなくバイト列を送る必要があります。

socket と ssl

addr = socket.getaddrinfo(host, 443)[0][-1]
sock = socket.socket()

socket.getaddrinfo(host, 443) は、hooks.slack.com の接続先アドレスを調べています。

443 は HTTPS の標準ポート番号です。

sock.connect(addr)
sock = ssl.wrap_socket(sock, server_hostname=host)

sock.connect(addr) で Slack のサーバーへ接続します。

その後、ssl.wrap_socket() で HTTPS 通信用に包みます。

Slack の Webhook URL は https:// なので、SSL/TLS が必要です。

HTTP リクエストを組み立てる

request = (
    "POST {} HTTP/1.1\r\n"
    "Host: {}\r\n"
    "Content-Type: application/json\r\n"
    "Content-Length: {}\r\n"
    "Connection: close\r\n"
    "\r\n"
).format(path, host, len(body)).encode("utf-8") + body

ここでは、Slack に送る HTTP リクエストを文字列で組み立てています。

POST は、サーバーにデータを送るための HTTP メソッドです。

Content-Type: application/json は、「送る本文は JSON です」という意味です。

Content-Length は、送る本文の長さです。

len(body)

で、JSON 本文のバイト数を数えています。

\r\n は HTTP の改行です。

普段の Python では改行に \n を使うことが多いですが、HTTP のヘッダーでは \r\n を使います。

最後の空行、

"\r\n"

によって、「ここまでがヘッダーで、ここから本文です」という区切りになります。

その後ろに、JSON の本文である body をつなげています。

+ body

finally で socket を閉じる

try:
    sock.connect(addr)
    sock = ssl.wrap_socket(sock, server_hostname=host)
    ...
finally:
    sock.close()

finally は、成功しても失敗しても最後に必ず実行される場所です。

ネットワーク接続を開いたら、最後に閉じる必要があります。

途中でエラーが起きても、

sock.close()

が実行されるように、finally に書いています。

これは重要です。

接続を開きっぱなしにすると、メモリや通信リソースを無駄に使ってしまう可能性があります。

実機に転送するファイル

今回からファイルが増えたので、ATOM Lite へ転送するファイルも増えます。

python -m mpremote connect COM7 cp src/config.py :config.py
python -m mpremote connect COM7 cp src/sensor.py :sensor.py
python -m mpremote connect COM7 cp src/slack_notify.py :slack_notify.py
python -m mpremote connect COM7 cp src/main.py :main.py

config.py には秘密情報が入っているため、Git 管理しないようにします。

動作確認として PC 上の src/main.py を一時実行する場合は、以下です。

python -m mpremote connect COM7 run src/main.py

ただし、main.pysensor.pyslack_notify.py を import するため、これらのファイルは先に ATOM Lite 側へ転送しておく必要があります。

今回の到達点

今回できるようになったことは、以下です。

  • センサー処理と Slack 通知処理を別ファイルに分けた
  • Wi-Fi に接続できるようにした
  • config.py で秘密情報を分けた
  • Slack Incoming Webhook に JSON を POST できるようにした
  • 温度と湿度を 1 時間に 1 回 Slack へ送れるようにした
  • センサーエラーと通知エラーを分けて表示できるようにした

次は、温度や湿度が適正範囲から外れたときだけ通知したり、メッセージの文面を状態に応じて変えたりできそうです。

run と reset の違い

mpremote には、runreset というコマンドがあります。

最初はどちらも「実行する」ように見えますが、役割は違います。

python -m mpremote connect COM7 run src/main.py

これは、PC 上の src/main.py を一時的に ATOM Lite へ送って実行するコマンドです。

PC の src/main.py
  ↓ 一時的に送る
ATOM Lite 上で実行

この場合、ATOM Lite 本体に main.py として保存されるわけではありません。

開発中に「今 PC で編集したコードをすぐ試す」ためのコマンドです。

一方で、

python -m mpremote connect COM7 reset

これは、ATOM Lite 本体を再起動するコマンドです。

ATOM Lite を再起動
  ↓
本体内に保存されている boot.py
  ↓
本体内に保存されている main.py

reset は PC 上の src/main.py を実行するわけではありません。

ATOM Lite 本体に保存済みの main.py が起動します。

つまり、ざっくり言うと以下です。

run   = PC 上のファイルを一時実行する
reset = 本体を再起動して、本体内の main.py を自動実行する

開発中に試すだけなら、以下で十分です。

python -m mpremote connect COM7 run src/main.py

ただし今回のようにファイルを分けている場合、main.py が import する sensor.pyslack_notify.py は、先に ATOM Lite 側へ転送しておく必要があります。

python -m mpremote connect COM7 cp src/sensor.py :sensor.py
python -m mpremote connect COM7 cp src/slack_notify.py :slack_notify.py
python -m mpremote connect COM7 cp src/config.py :config.py

本体へ保存して、電源を入れたら自動で動く状態にするには、main.py も本体へ保存します。

python -m mpremote connect COM7 cp src/main.py :main.py

その後、保存済みのコードで起動するか確認するには、以下を実行します。

python -m mpremote connect COM7 reset

通電開始で main.py が起動する理由

ATOM Lite に電源を入れると、本体に保存済みの main.py が自動で起動します。

これは MicroPython の決まりです。

MicroPython では、ボードが起動すると、決まった名前のファイルを探して実行します。

起動時の流れは、ざっくり以下です。

ATOM Lite に電源が入る
↓
MicroPython ファームウェアが起動する
↓
boot.py があれば実行する
↓
main.py があれば実行する

つまり、main.py というファイル名には特別な意味があります。

PC から以下のように保存すると、

python -m mpremote connect COM7 cp src/main.py :main.py

ATOM Lite 本体の中には、次のファイルが置かれます。

/main.py

MicroPython は起動時にこの /main.py を見つけると、自動で実行します。

そのため、USB 電源を挿したときも、

電源 ON
↓
boot.py
↓
main.py
↓
Slack 通知プログラム開始

という流れになります。

boot.py は、起動直後の初期設定用です。

今回の boot.py では、デバッグ出力を抑えたり、ガベージコレクションを有効にしたりしています。

import gc
import esp

esp.osdebug(None)
gc.enable()

一方、main.py は実際のアプリ本体を置く場所です。

今回のような常時動かしたいプログラムは、ATOM Lite 本体に main.py として保存しておくと、通電開始で自動起動します。

ATOM Lite はバッテリー内蔵ではないので、USB ケーブルを抜くと基本的に電源 OFF になります。

再度 USB 電源や USB 充電器につなぐと電源 ON になり、保存済みの main.py が自動で実行されます。

ヘラクレスオオカブト生育環境を最適化する(4)~センサから温湿度を取得~

M5Stack ATOM Lite と ENV III Unit を使って、温度と湿度を MicroPython で読み取るところまでをまとめます。

今回のコード

from machine import I2C, Pin
import time

BTN_PIN = 39
I2C_SCL_PIN = 32
I2C_SDA_PIN = 26
SHT30_ADDR = 0x44
READ_INTERVAL_MS = 2000

btn = Pin(BTN_PIN, Pin.IN, Pin.PULL_UP)
i2c = I2C(0, scl=Pin(I2C_SCL_PIN), sda=Pin(I2C_SDA_PIN), freq=100000)


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


def read_sht30():
    i2c.writeto(SHT30_ADDR, b"\x24\x00")
    time.sleep_ms(20)
    data = i2c.readfrom(SHT30_ADDR, 6)

    if crc8(data[0:2]) != data[2] or crc8(data[3:5]) != data[5]:
        raise OSError("SHT30 CRC check failed")

    raw_temp = (data[0] << 8) | data[1]
    raw_humidity = (data[3] << 8) | data[4]
    temperature = -45 + (175 * raw_temp / 65535)
    humidity = 100 * raw_humidity / 65535
    return temperature, humidity


def scan_i2c():
    devices = i2c.scan()
    print("i2c devices:", [hex(device) for device in devices])
    return devices


def main():
    print("atom lite ready. reading ENV III SHT30 temperature/humidity.")
    scan_i2c()

    while True:
        try:
            temperature, humidity = read_sht30()
            print("temperature: {:.2f} C, humidity: {:.2f} %".format(temperature, humidity))
        except OSError as error:
            print("sensor error:", error)

        if btn.value() == 0:
            scan_i2c()
            time.sleep_ms(300)

        time.sleep_ms(READ_INTERVAL_MS)


if __name__ == "__main__":
    main()

python -m py_compile src/main.py とは

python -m py_compile src/main.py

これは PC 上の Python で、src/main.py に文法エラーがないかを確認するコマンドです。

  • python: PC 上の Python を起動
  • -m py_compile: py_compile という標準モジュールを実行
  • src/main.py: チェック対象のファイル

ただし、これは MicroPython 実機での動作確認ではありません。

machine モジュールや time.sleep_ms() は MicroPython 用の機能なので、PC 上ではそのまま実行できません。あくまで「Python の構文として壊れていないか」を見るための軽い確認です。

devices = i2c.scan() とは

devices = i2c.scan()

これは、I2C バスにつながっている機器を探すコードです。

ENV III がうまく接続できていれば、たとえば次のような値が返ります。

devices = [68, 112]

これは 10 進数です。

I2C の資料では 16 進数でアドレスが書かれていることが多いので、読み替えるとこうなります。

68   # 0x44: SHT30 温湿度センサー
112  # 0x70: QMP6988 気圧センサー

つまり i2c.scan() は、「いま I2C 上に見えている機器のアドレス一覧」をリストで返します。

リスト内包表記

[hex(device) for device in devices]

これはリスト内包表記です。

意味としては、

devices から値を 1 個ずつ取り出して、hex() で 16 進数表記に変換し、新しいリストを作る

という処理です。

通常の for 文で書くと、次のようになります。

result = []

for device in devices:
    result.append(hex(device))

たとえば、

devices = [68, 112]

だった場合、

[hex(device) for device in devices]

の結果は、

['0x44', '0x70']

になります。

scan_i2c() 関数では、表示するときだけ 16 進数の文字列に変換しています。

print("i2c devices:", [hex(device) for device in devices])

一方で、戻り値として返しているのは、i2c.scan() が返した元のリストです。

return devices

つまり、戻り値は ['0x44', '0x70'] ではなく、[68, 112] のような整数リストです。

Python の関数は戻り値の型を書かなくていい

Python では、関数定義に戻り値の型を書かなくても動きます。

def scan_i2c():
    devices = i2c.scan()
    print("i2c devices:", [hex(device) for device in devices])
    return devices

この関数は devices を返していますが、Java のように「この関数は List を返します」とは書いていません。

戻り値を書かない関数も作れます。

def hello():
    print("hello")

この場合、実は None が返ります。

型を書きたい場合は、Python にも型ヒントがあります。

def add(a: int, b: int) -> int:
    return a + b

ただし、型ヒントは主に説明や静的チェック用です。Java のように実行時に厳格に強制されるものではありません。

MicroPython のコードでは、最初は型ヒントなしでシンプルに書いてよさそうです。

i2c.writeto(SHT30_ADDR, b"\x24\x00") のバイト列

i2c.writeto(SHT30_ADDR, b"\x24\x00")

これは、SHT30 センサーに対してコマンドを送っている行です。

SHT30_ADDR0x44 です。

SHT30_ADDR = 0x44

第 2 引数の、

b"\x24\x00"

は、2 バイトの bytes 型です。

中身は、

0x24
0x00

の 2 バイトです。

これは SHT30 に対して、

温度と湿度を 1 回測定してね

と伝えるためのコマンドです。

I2C 通信では、人間に読める文字列を送るというより、センサーの仕様で決められた命令番号をバイトで送ります。

CRC チェック

if crc8(data[0:2]) != data[2] or crc8(data[3:5]) != data[5]:
    raise OSError("SHT30 CRC check failed")

ここでは、SHT30 から受け取ったデータが壊れていないか確認しています。

SHT30 から読む 6 バイトは、次のような並びです。

data = [
    温度上位バイト,
    温度下位バイト,
    温度CRC,
    湿度上位バイト,
    湿度下位バイト,
    湿度CRC,
]

具体例として、

data = b"\x66\x80\x93\x7a\x20\x7d"

のようなデータが来たとします。

位置で見ると、以下のようになります。

index:  0     1     2     3     4     5
data:  0x66  0x80  0x93  0x7a  0x20  0x7d
       温度   温度   CRC   湿度   湿度   CRC

data[0:2] はスライスです。

data[0:2]

これは、0 番目から 2 番目の手前までを取り出します。つまり data[0]data[1] です。

終わりの番号は含まれません。

そのため、

data[0:2]

は温度データ 2 バイト、

data[3:5]

は湿度データ 2 バイトです。

一方、

data[2]

は温度用 CRC、

data[5]

は湿度用 CRC です。

crc8(data[0:2]) != data[2] は、

温度 2 バイトから CRC を再計算して、センサーが送ってきた CRC と一致するか確認する

という処理です。

CRC は、温度 2 バイトを結合した値ではありません。

CRC は、元データから計算して作るチェック用の 1 バイトです。

たとえば、

crc8(b"\x66\x80")

の結果が 0x93 になった場合、

crc8(data[0:2]) != data[2]

は、

0x93 != 0x93

となり、これは False です。

つまり、CRC は合っていて、データは壊れていなさそうだと判断できます。

もし通信途中でデータが化けると、再計算した CRC とセンサーが送ってきた CRC が一致しません。その場合は、

raise OSError("SHT30 CRC check failed")

でエラーにします。

raiseOSError

raise OSError("SHT30 CRC check failed")

raise は、エラーを発生させる命令です。

return が「正常に関数から戻る」だとすると、raise は「異常が起きたので通常の処理を中断する」というイメージです。

OSError は Python に用意されているエラーの種類の 1 つです。

ファイル、通信、デバイス操作など、入出力まわりで問題が起きたときによく使われます。

今回の場合は、SHT30 との通信結果が正しくなさそうなので、OSError として扱っています。

このエラーは、あとで main() 側の except で受け取ります。

try:
    temperature, humidity = read_sht30()
    print("temperature: {:.2f} C, humidity: {:.2f} %".format(temperature, humidity))
except OSError as error:
    print("sensor error:", error)

つまり、読み取りに失敗したときでもプログラム全体を止めずに、エラー表示をして次のループに進めるようにしています。

2 バイトを 1 つの数値にする

raw_temp = (data[0] << 8) | data[1]

これは、温度の上位 1 バイトと下位 1 バイトをくっつけて、16 ビットの数値にする処理です。

ここは初心者にはかなり難しいところです。

まず、バイト はデータの大きさの単位です。

1 バイト = 8 ビット

1 ビットは 01 のどちらかです。

1 バイトは 8 個のビットを並べたものなので、たとえば次のような形になります。

01100110

16 進数では、これを短く 0x66 と書けます。

SHT30 の温度データは 2 バイトで届く

SHT30 から来る温度データは、1 バイトではなく 2 バイトです。

data[0]  # 温度の上位バイト
data[1]  # 温度の下位バイト

なぜ 2 バイトなのかというと、1 バイトだけでは表せる数が少ないからです。

1 バイトで表せる値は、以下の範囲です。

0 から 255 まで

温度センサーはもっと細かい値を表したいので、2 バイトを使って大きな数を表します。

2 バイトは 16 ビットなので、以下の範囲を表せます。

0 から 65535 まで

この 0 から 65535 の値を、あとで摂氏温度に変換します。

上位バイトと下位バイト

「上位バイト」「下位バイト」という言葉も最初は分かりにくいです。

ざっくり言うと、2 バイトの数値を作るときの、

左側のバイト = 上位バイト
右側のバイト = 下位バイト

です。

たとえば、SHT30 から次の 2 バイトが届いたとします。

data[0] = 0x66
data[1] = 0x80

このとき、data[0] が上位バイト、data[1] が下位バイトです。

図にするとこうです。

data[0]          data[1]
上位バイト       下位バイト
0x66             0x80

↓ 2 つを並べて 1 つの数値にする

0x6680

イメージとしては、2 つの箱を横に並べる感じです。

+--------+--------+
|  0x66  |  0x80  |
+--------+--------+
   上位     下位

=> 0x6680

10 進数の感覚に近づけるなら、たとえば 1234 を並べて 1234 にするようなものです。

12 と 34 を並べる

+----+----+
| 12 | 34 |
+----+----+

=> 1234

ただし、コンピューターでは 10 進数ではなく、2 進数や 16 進数の単位で同じことをしています。

今回作りたいのは、

0x6680

という 1 つの 16 ビットの数値です。

なぜ << 8 するのか

ここで出てくるのが、左シフトです。

<< は左シフトです。

data[0] << 8

は、data[0] のビットを左に 8 個ずらします。

なぜ 8 個ずらすのかというと、1 バイトが 8 ビットだからです。

data[0] は、最初は 1 バイトぶんの場所にいます。

0x66

これを上位バイトの位置に持っていきたいので、8 ビットぶん左にずらします。

16 進数で見ると、2 桁ぶん左にずらすイメージです。

0x66 << 8

は、

0x6600

になります。

図にすると、次のようなイメージです。

最初:

0x0066

8 ビット左にずらす:

0x6600

ここまでで、上位バイトは正しい場所に移動できました。

| data[1] で下位バイトを入れる

次に、空いている下位バイトの場所に data[1] を入れます。

(data[0] << 8) | data[1]

| はビット単位の OR です。

今回の例だと、左側は 0x6600 です。

右側の data[1]0x80 ですが、桁をそろえて書くと 0x0080 です。

0x6600 | 0x0080

これを OR すると、

  0x6600
| 0x0080
--------
  0x6680

となります。

結果は、

0x6680

になります。

コード全体で見ると、以下の流れです。

raw_temp = (data[0] << 8) | data[1]
data[0] = 0x66
data[1] = 0x80

data[0] << 8
=> 0x66 << 8
=> 0x6600

(data[0] << 8) | data[1]
=> 0x6600 | 0x0080
=> 0x6680

同じことは、次のようにも書けます。

raw_temp = data[0] * 256 + data[1]

1 バイト = 8 ビット で、2 ** 8 = 256 だからです。

今回の例なら、

raw_temp = 0x66 * 256 + 0x80

10 進数にすると、

raw_temp = 102 * 256 + 128

なので、

raw_temp = 26240

になります。

この 26240 は、まだ摂氏温度ではありません。

あくまで SHT30 が返してきた「温度の生データ」です。

次の式で、この生データを摂氏温度に変換します。

生データを温度と湿度に変換する

temperature = -45 + (175 * raw_temp / 65535)
humidity = 100 * raw_humidity / 65535

ここでは、SHT30 から受け取った生の数値を、人間が読める温度と湿度に変換しています。

SHT30 は、最初から、

温度: 24.6 C
湿度: 58.2 %

のような値を返してくれるわけではありません。

代わりに、16 ビットの生データを返します。

そのため、データシートで決められた式を使って、摂氏温度と相対湿度に変換します。

温度の式は以下です。

temperature = -45 + (175 * raw_temp / 65535)

SHT30 の温度データは、おおまかに、

0     -> -45 C
65535 -> 130 C

に対応しています。

差は 175 なので、式の中に 175 が出てきます。

湿度はもっと素直です。

humidity = 100 * raw_humidity / 65535

これは、

0     -> 0 %
65535 -> 100 %

に対応しています。

65535 は、16 ビットで表せる最大値です。

0x0000 = 0
0xffff = 65535

ボタンを押したら I2C を再スキャンする

if btn.value() == 0:
    scan_i2c()
    time.sleep_ms(300)

ここは、ATOM Lite 本体のボタンを押したときに、I2C スキャンをもう一度実行する処理です。

btn は以下で作っています。

btn = Pin(BTN_PIN, Pin.IN, Pin.PULL_UP)

Pin.PULL_UP を使っているので、ボタンの値は以下のようになります。

押していない -> 1
押している   -> 0

そのため、

if btn.value() == 0:

は「ボタンが押されていたら」という意味です。

ボタンが押されたら、

scan_i2c()

で I2C 上の機器一覧を表示します。

最後の、

time.sleep_ms(300)

は、ボタンを 1 回押しただけなのに何度も反応するのを少し防ぐための待ち時間です。

この処理は必須ではありません。温湿度を定期的に読むだけなら消しても動きます。

if __name__ == "__main__":

if __name__ == "__main__":
    main()

これは、

このファイルが直接実行されたときだけ main() を呼ぶ

という Python の定番の書き方です。

Python では、ファイルが実行されると、そのファイル内の特別な変数 __name__ に値が入ります。

直接実行された場合は、

__name__ = "__main__"

のような状態になります。

そのため、

if __name__ == "__main__":

True になり、main() が呼ばれます。

一方で、別のファイルから import main された場合、__name__"__main__" ではなく、だいたい "main" になります。

その場合、main() は勝手には実行されません。

MicroPython の main.py では、正直なくても動くことは多いです。

ただし、この形にしておくと、後で別ファイルから部品として import したくなったときに安全です。

crc8() 関数

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

これは、渡されたバイト列から SHT30 用の CRC 1 バイトを計算する関数です。

最初の、

crc = 0xFF

は CRC の初期値です。SHT30 の CRC 計算では、最初の値を 0xFF から始めるルールになっています。

for value in data:

は、渡されたバイト列を 1 バイトずつ取り出します。

たとえば、

data = b"\x66\x80"

なら、0x660x80 の順番で処理します。

crc ^= value

^ はビット単位の XOR です。

^= は、

crc = crc ^ value

の省略形です。

XOR は、ビットごとに見て「同じなら 0、違えば 1」になります。

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

つまり crc ^= value は、今の CRC に今回の 1 バイトを混ぜ込む処理です。

for _ in range(8):

これは 8 回繰り返すという意味です。

1 バイトは 8 ビットなので、1 バイト分の CRC 処理を 8 回行います。

_ は「繰り返し回数の値は使わない」という慣習的な変数名です。

if crc & 0x80:

& はビット単位の AND です。

0x80 は 2 進数で、

10000000

です。

つまり crc & 0x80 は、「crc の一番左のビットが 1 かどうか」を調べています。

一番左のビットが 1 なら、

crc = (crc << 1) ^ 0x31

を実行します。

crc << 1 は crc を左に 1 ビットずらす処理です。

0x31 は SHT30 の CRC 計算で使う決まった値です。専門的には多項式と呼ばれます。

一番左のビットが 0 なら、

crc <<= 1

だけを実行します。

これは、

crc = crc << 1

と同じです。

最後に、

crc &= 0xFF

を実行しています。

これは、

crc = crc & 0xFF

の省略形です。

0xFF は 2 進数で、

11111111

です。

つまり、crc を下位 8 ビットだけに切り詰めています。

Python の整数はサイズが自動で大きくなりますが、CRC8 は 8 ビットの CRC です。

そのため毎回、

crc &= 0xFF

で 8 ビットに戻しています。

mpremote が見つからなかった

最初に以下を実行したところ、

python -m mpremote devs

次のエラーになりました。

No module named mpremote

これは、通常の Python 環境に mpremote が入っていないという意味です。

このリポジトリには .venv があり、その中には mpremote がインストールされていました。

そのため、先に仮想環境を有効化します。

.\.venv\Scripts\Activate.ps1

プロンプトの先頭に (.venv) が付いたら OK です。

その状態で、

python -m mpremote devs

を実行します。

runcp の違い

mpremote では、次の 2 つの使い方をしました。

python -m mpremote connect COM7 run src/main.py

これは、PC 上の src/main.py を ATOM Lite に一時的に送って実行するコマンドです。

ATOM Lite 本体の main.py を書き換えるわけではありません。

開発中に「今編集しているコードをすぐ試す」用途に向いています。

一方、

python -m mpremote connect COM7 cp src/main.py :main.py

これは、PC 上の src/main.py を ATOM Lite 本体の main.py として保存するコマンドです。

ATOM Lite を再起動した後も動かしたい場合は、こちらで本体へ保存する必要があります。

ざっくり言うと、以下です。

run = PC 上のファイルを一時実行。試す用。
cp  = ATOM Lite 本体へ保存。完成版を入れる用。

I2C のピン指定でつまずいた

最初は、ATOM Lite の I2C ピンとして以下を設定していました。

I2C_SCL_PIN = 21
I2C_SDA_PIN = 25

しかし、実行すると以下のようになりました。

i2c devices: []
sensor error: [Errno 19] ENODEV

i2c devices: [] は、「I2C 上に何も見つからない」という意味です。

ATOM Lite の公式資料を見ると、本体内部の I2C と Grove 端子のピンが別でした。

  • 本体内部の I2C: G21, G25
  • 外部 Grove/HY2.0-4P 端子: G26, G32

ENV III は Grove ケーブルで外部端子につないでいるため、G26, G32 を使う必要があります。

さらに、ここで少し紛らわしい点がありました。

Arduino の例では、

Wire.begin(26, 32)

のように書かれています。

Arduino の Wire.begin(26, 32) は、順番が SDA, SCL です。

つまり、

SDA = 26
SCL = 32

です。

一方、MicroPython のコードでは、名前付き引数で以下のように指定しています。

i2c = I2C(0, scl=Pin(I2C_SCL_PIN), sda=Pin(I2C_SDA_PIN), freq=100000)

そのため、最終的な設定はこうなりました。

I2C_SCL_PIN = 32
I2C_SDA_PIN = 26

この修正後、I2C スキャンで ENV III のセンサーが見えるようになりました。

i2c devices: ['0x44', '0x70']

実行結果

最終的に、以下のコマンドで実行しました。

python -m mpremote connect COM7 run src/main.py

結果は以下です。

atom lite ready. reading ENV III SHT30 temperature/humidity.
i2c devices: ['0x44', '0x70']
temperature: 28.58 C, humidity: 50.52 %
temperature: 28.61 C, humidity: 50.54 %
temperature: 28.58 C, humidity: 50.55 %
temperature: 28.60 C, humidity: 50.51 %
temperature: 28.58 C, humidity: 50.57 %

0x44 は SHT30 温湿度センサー、0x70 は QMP6988 気圧センサーです。

今回のコードでは 0x44 の SHT30 だけを読んでいますが、I2C スキャンでは QMP6988 も見えていることが確認できました。

これで、ATOM Lite と ENV III を使って、温度と湿度を MicroPython で取得するところまで進めました。

今回の到達点

今回できたことは以下です。

  • L チカコードを外して、温湿度取得コードに置き換えた
  • I2C スキャンで ENV III のセンサーを確認した
  • SHT30 に測定コマンドを送った
  • 温度と湿度の 6 バイトを読み取った
  • CRC でデータ破損をチェックした
  • 生データを摂氏温度と相対湿度に変換した
  • ATOM Lite の Grove 端子では SCL=32, SDA=26 を使うことが分かった

次は、測定した温湿度を記録したり、一定範囲を外れたら通知する処理に進めそうです。

ヘラクレスオオカブト生育環境を最適化する(3)~温湿度センサの前提知識~

センサの前提知識

温湿度センサーは現代では I2C が主流。組み込み初心者にとって最も扱いやすい。

通信方式 特徴 配線本数
アナログ 電圧で値を返す(古典的) 1〜数本
1-Wire / 独自プロトコル 1本の信号線で通信 1本
I2C ⭐ 2本線で通信、複数機器繋げる 2本
SPI 4本線で高速通信 4本

温湿度センサーの代表選手

型番 通信 精度 価格 備考
DHT11 独自1線 低(湿度±5%) 安い(¥300) 入門向けだが最近は非推奨
DHT22 独自1線 中(湿度±2%) ¥800 DHT11の上位
AHT20 I2C ¥500 安くて定番
SHT30/31 I2C ¥1000〜 プロ用途定番
SHT40 I2C ¥1500 新型、低消費電力
BME280 I2C/SPI ¥1500 気圧も測れるボーナス付き

M5Stackには Grove互換のセンサーユニットが用意されています。Atom LITEの底面のGroveコネクタにケーブル1本でカチッと挿すだけで完了:

初心者にはENV系が強くおすすめ。理由:

  • ハンダ付け不要
  • ブレッドボード不要
  • Groveケーブル1本で完結
  • M5Stack公式サンプルが豊富

I2C 通信の基本知識(必須)

I2C は「住所付き2本線通信」

[Atom LITE]
   │
   ├─ SDA (データ線) ─┬─→ [SHT40センサー  住所:0x44]
   ├─ SCL (クロック線)─┤
   ├─ 3.3V (電源)    ─┤
   └─ GND (グランド) ─┘

各センサーにはI2Cアドレス(住所)が振られていて、コードでは「住所0x44のセンサーから2バイト読み取り」のような感覚で使う。

Atom LITE の Grove ポート

PinMapから読み取れる情報:

Grove (HY2.0-4P コネクタ)
 ├ GND  (グランド)
 ├ 5V   (電源)
 ├ G26  (デフォルトI2C: SDA)
 └ G32  (デフォルトI2C: SCL)

つまりGrove対応センサーを挿せば、G26 と G32 が自動的にI2Cバスになる配線になっています。

I2Cの典型的な手順

from machine import I2C, Pin

# 1. I2Cバスを初期化
i2c = I2C(0, sda=Pin(26), scl=Pin(32), freq=100000)

# 2. 接続確認(住所スキャン)
print(i2c.scan())  # [0x44] など、見つかった機器のアドレス一覧

# 3. データ読み書き
i2c.writeto(0x44, b'\xfd')           # コマンド送信
data = i2c.readfrom(0x44, 6)          # 6バイト受信

ライブラリの扱い方

ここが MicroPython の独特なところ:

MicroPython にはpipが無い、代わりに mip

デバイス上で実行

import mip
mip.install("github:author/library_name")

または ライブラリのファイルを手動でデバイスに転送する のが一般的:

mpremote connect COM7 fs cp my_sensor_lib.py :lib/sensor.py

主要センサーは公式ライブラリがある

  • SHT30/31/40 → micropython-sht3x
  • BME280 → micropython-bme280
  • AHT20 → micropython-ahtx0
  • DHT11/22 → MicroPython に標準内蔵( import dht

公式ライブラリが無い変態センサーを買うと、データシートを読みながら自分で書くハメになります。初心者は公式ライブラリありのセンサーを選ぶのが鉄則。

コーディングのお作法

ライブラリが「センサー固有の通信プロトコル」を隠してくれるので、ユーザーは値を取り出すだけ。

# ① 通信を確立
i2c = I2C(0, sda=Pin(26), scl=Pin(32))

# ② センサーを初期化(オブジェクト生成)
sensor = SHT40(i2c)

# ③ 値を読む(ループ内で繰り返し)
while True:
    temp = sensor.temperature()
    humid = sensor.humidity()
    print(f"温度: {temp:.1f}℃  湿度: {humid:.1f}%")
    time.sleep(2)

ENV III の中身

センサー 役割 I2Cアドレス
SHT30 温度・湿度 0x44
QMP6988 気圧 0x70

1つのGroveユニットの中に2つのI2Cセンサーが入っている構造。アドレスが違うので、I2Cバス1本で両方とも独立して通信できます。

ステップ1: I2Cスキャン(接続確認)

センサーが認識されているかをまず確認。コードはこんなだけ:

from machine import I2C, Pin
i2c = I2C(0, sda=Pin(26), scl=Pin(32), freq=100000)
print(i2c.scan())

期待結果:[68, 112] (= 0x44, 0x70 の10進表記)。これが出れば配線とI2C初期化が正しい証拠。

ステップ2: SHT30 だけで温湿度を読む

まずはシンプルに温湿度のみ。気圧は後回し。理由:

  • SHT30 は MicroPython ライブラリが豊富
  • 動作確認の最短経路

ステップ3: QMP6988 で気圧も追加

気圧はオプション。MicroPython用の既存ライブラリが少ないので、自作になる可能性あり。SHT30で慣れてから手をつけるのが安全。

実装に必要な4つの知識:

  • ビット・バイト・16進数(基礎)
  • I2C 通信プロトコル(マイコンとセンサーの会話の規格)
  • SHT30 という具体的なセンサーの仕様(何を喋れば温度が返ってくるか)
  • データシートの読み方(仕様書)

ビット・バイト・16進数(必須の足腰)

センサーとの通信は「生のバイト列」を扱うので、ここの感覚が無いと詰まります。

  • ビットとバイト

1ビット = 0 か 1(電気的にはOFF/ON) 1バイト = 8ビット = 0〜255 の数値

  • 16進数

8ビット(1バイト)を 2桁の16進数で書くと読みやすい:

2進数:    0000 0000 〜 1111 1111
10進数:   0   〜 255
16進数:   0x00 〜 0xFF      ← Pythonでは「0x」プレフィックス

例:

  • 0x44 = 10進で68 = 2進で 0100 0100
  • 0xFF = 10進で255 = 2進で 1111 1111
  • 0x2C = 10進で44 = 2進で 0010 1100

Pythonでのバイト表現

b'\x2c\x06' # 2バイトのバイト列。0x2C と 0x06 が並んでる

I2C 通信プロトコル

I2C は何のためにある?

「1台のマイコンが複数のセンサー/機器と少ない配線で会話するため」の規格。Philips(NXP)が1980年代に作った標準。

物理的に何が起きているか

I2Cバスは SDA(データ線)SCL(クロック線)2本の電線

Atom LITE ─── SDA ──┬─── SDA ── SHT30
(マスター)            │             ↑
              ─── SCL ──┼─── SCL ── (アドレス 0x44)
                       │
                       └─── ... 他のI2C機器
                            QMP6988 (アドレス 0x70)

1本のバスに複数の機器がぶら下がれるのがI2Cの強み。アドレスで誰と話すか決める。

マスターとスレーブ(Controller/Target)

  • マスター(主導権): マイコン側 = Atom LITE。会話の主導権を持つ
  • スレーブ(従属): センサー側 = SHT30。マスターから話しかけられて返事する

スレーブは自分から喋れません。マスターが「君の番だよ」と問いかけて、初めて返事する

「住所(アドレス)」の仕組み

各I2C機器には7ビットのアドレス(0x00〜0x7F、つまり0〜127)が割り当てられています。チップごとに製造時に決まっている(変更不可。一部例外あり)。

SHT30 → 0x44(68) QMP6988 → 0x70(112)

I2Cの最初のステップは 「アドレス0x44の機器、聞いてる?」 と呼びかけること。

i2c.writeto(0x44, b'\x2c\x06')      # パターンA: 0x44に2バイト書く
data = i2c.readfrom(0x44, 6)        # パターンB: 0x44から6バイト読む

プルアップ抵抗(参考)

  • I2Cバスは「電気的に何もしないと1(HIGH)になる」設計が必要で、これを プルアップ抵抗 で実現します。
  • 安心していい:M5Stack ENV III は基板上にプルアップ抵抗が最初から載っているので、ユーザーは何もしなくてOK。

SHT30 とは

製造元と立ち位置

  • メーカー: Sensirion(スイスのセンサー専業メーカー、業界標準)
  • 製品: 温度・湿度を1チップで測れるI2Cセンサー
  • サイズ: 米粒くらい(2.5mm × 2.5mm)

センサー専業メーカーの製品は信頼性が高く、データシートも丁寧。Sensirion製は迷ったら選んで間違いないブランド。

中で何をやっているか

[SHT30の中身]
 ├─ 温度センサー(バンドギャップ素子)
 ├─ 湿度センサー(容量式:吸湿で電気容量が変わる素子)
 ├─ ADC(アナログ→デジタル変換)
 ├─ 温度補正ロジック
 ├─ I2C通信回路
 └─ レジスタ(測定結果の保存場所)

つまり「アナログ素子 + ADC + 補正処理 + I2C通信」が1チップに統合されてる。生のアナログ値ではなく、処理済みのデジタル値をI2Cでくれるので、ユーザーは深く考えなくて良い。

SHT30 への話しかけ方

データシートに「コマンド一覧」が載っています。代表的なもの:

16進コマンド 意味
0x2C 0x06 1回測定して、高精度で結果を返せ
0x2C 0x0D 1回測定して、中精度で返せ
0x2C 0x10 1回測定して、低精度で返せ
0x30 0xA2 センサーをリセット
0xF3 0x2D ステータスレジスタを読む

「コマンドは2バイト」がSHT30の流儀。今回使うのは 0x2C 0x06(高精度測定)です。

SHT30 からの返事の構造

0x2C 0x06 を投げて少し待つと、6バイトの返事が返ってきます:

[B0][B1][B2] [B3][B4][B5]
 │   │   │    │   │   │
 │   │   │    │   │   └─ 湿度データのCRC(誤り検出用)
 │   │   │    └───┴────── 湿度の生データ(2バイト = 16ビット数値)
 │   │   └──────────────── 温度データのCRC
 └───┴──────────────────── 温度の生データ(2バイト = 16ビット数値)

各2バイトを16ビット整数に組み立てて、変換式にかけると℃と%が出る:

# データシート記載の公式
温度℃ = -45 + 175 × (生温度値 / 65535)
湿度% = 100 × (生湿度値 / 65535)

65535 は16ビットの最大値(0xFFFF)。生データを0〜1の比率に直して、温度/湿度の物理範囲にマッピングする仕組み。

CRC(巡回冗長検査)

通信中にビットが化けることがある(ノイズ等)ので、データの直後に検査用バイトをくっつける仕組み。

  • 受信したデータからCRCを計算 → 受信したCRCバイトと一致すればOK
  • 一致しなければ通信エラーとして再送信

今回は最初は無視してOK。動かしてから後で追加すれば良い。

データシートとは

何が書いてあるか?

電子部品メーカーが出す仕様書PDF。この中に:

  • 電気特性(動作電圧、消費電流)
  • ピン配置(センサーICのどの足が何用か)
  • タイミング図(信号の波形と時間制約)
  • コマンド表(書き込む値とその意味)
  • 応答フォーマット(返ってくるデータの並び方)
  • 計算式(生データを物理値に変換する公式)
  • 応用例(推奨回路)

SHT30 のデータシート

Sensirion公式から PDF が落とせます:

https://sensirion.com/products/catalog/SHT30-DIS-B/

英語ですが、Section 4「Operation and Communication」が今回の本丸。コマンド表と応答フォーマットがここに集約されています。

データシートは全部読む必要なし。目的別に拾い読みするもの:

やりたいこと データシートのどこを見る
とりあえず動かす コマンド表、応答フォーマット、変換式
配線する ピン配置、推奨回路
高精度化する タイミング規定、補正計算
トラブル解決 電気特性、エラーコード

今回の実装で必要な最低限の知識

知識 中身
Atom LITEのGroveピン SDA=G26, SCL=G32
SHT30のアドレス 0x44
測定コマンド 0x2C 0x06(2バイト送信)
応答 6バイト(温度2 + CRC1 + 湿度2 + CRC1)
温度変換式 -45 + 175 × (生値 / 65535)
湿度変換式 100 × (生値 / 65535)
タイミング コマンド送信後、約15ms待ってから読み取る

実装の流れ

# 1. I2Cバス初期化
i2c = I2C(0, sda=Pin(26), scl=Pin(32))

# 2. スキャンで0x44が見えるか確認
print(i2c.scan())  # → [0x44, 0x70] が期待値

# 3. 測定コマンド送信
i2c.writeto(0x44, b'\x2c\x06')

# 4. 待つ(測定時間)
time.sleep_ms(20)

# 5. 6バイト読む
data = i2c.readfrom(0x44, 6)

# 6. バイトから16ビット値を組み立て
raw_temp = (data[0] << 8) | data[1]
raw_humid = (data[3] << 8) | data[4]

# 7. 物理値に変換
temp = -45 + 175 * raw_temp / 65535
humid = 100 * raw_humid / 65535

print(f"温度: {temp:.1f}℃  湿度: {humid:.1f}%")

物理的にケーブルはどうつながっているのか

[Atom LITE基板の半田部] ════銅線════ [SHT30の足]
                       SDA信号が
                       電圧の上下で伝わる
時間 →
SDA: ─┐ ┌──┐  ┌──── ← 電圧の上下が「ビット列」を表現
       └─┘  └──┘
SCL: ─┐  ┌─┐ ┌─┐ ┌─ ← クロック(タイミング合わせ)
       └──┘  └─┘  └
[Atom LITE] -- 黒 ----- [ENV III の GND端子]
            -- 赤 ----- [ENV III の 5V端子]
            -- 白 ----- [ENV III の SCL端子]
            -- 黄 ----- [ENV III の SDA端子]
                          │
                          ├ SHT30のSCL/SDAに分岐
                          └ QMP6988のSCL/SDAに分岐
                          (基板上で配線されている)

ENV IIIユニットの中では、SHT30とQMP6988が同じI2Cバスにぶら下がっている(基板内配線)。だから2つのセンサーが別々のアドレスで同居できる。

ヘラクレスオオカブト生育環境を最適化する(2)~Lチカコードと環境構築~

ボタンを押すたびに色が変わるコード

from machine import Pin
import neopixel
import time

LED_PIN = 27
BTN_PIN = 39
LED_COUNT = 1

COLORS = [
    (30, 0, 0),
    (0, 30, 0),
    (0, 0, 30),
    (30, 30, 30),
]

np = neopixel.NeoPixel(Pin(LED_PIN), LED_COUNT)
btn = Pin(BTN_PIN, Pin.IN, Pin.PULL_UP)


def set_color(index):
    np[0] = COLORS[index]
    np.write()


def main():
    color_index = 0
    set_color(color_index)
    print("atom lite ready. press button to cycle color.")

    last_state = btn.value()
    while True:
        state = btn.value()
        if last_state == 1 and state == 0:
            color_index = (color_index + 1) % len(COLORS)
            set_color(color_index)
            print("color:", color_index)
            time.sleep_ms(50)
        last_state = state
        time.sleep_ms(10)


if __name__ == "__main__":
    main()

import(1〜3行目)

from machine import Pin    # GPIOピン操作。MicroPython組み込み
import neopixel            # RGB LED(SK6812)制御。MicroPython組み込み
import time                # sleep等の時間制御。MicroPython組み込み

Javaで言うと import java.io.File と同じ。from X import Y は「Xモジュールの中のYだけ取り出す」構文。

定数(5〜14行目)

LED_PIN = 27     # RGB LEDが繋がっているGPIOの番号(Atom LITEの仕様で固定)
BTN_PIN = 39     # ボタンが繋がっているGPIOの番号(同上)
LED_COUNT = 1    # LEDの個数(Atom LITEは1個)

初期化(16〜17行目)

np = neopixel.NeoPixel(Pin(LED_PIN), LED_COUNT)
  • Pin(27) → GPIO27番を表すオブジェクト
  • NeoPixel(ピン, LED個数) → そのピンに繋がったRGB LEDを制御するオブジェクト
btn = Pin(BTN_PIN, Pin.IN, Pin.PULL_UP)
  • Pin.IN → 入力モード(ボタンの状態を読むので)
  • Pin.PULL_UP → 内部プルアップ抵抗を有効化。ボタンを押していない時=1、押した時=0 になる

プルアップとは: ピンをデフォルトで「1(HIGH)」にしておく仕組み。これが無いとボタンを押していない時にピンの値が不安定(0か1かわからない)になる。

set_color関数(20〜22行目)

def set_color(index):
    np[0] = COLORS[index]   # LEDの0番目にRGB値をセット(メモリ上だけ)
    np.write()               # 実際にLEDへ信号を送って光らせる

np[0] は配列のようにアクセス。LED1個なのでインデックスは常に0。.write() を呼ぶまでは光が変わらない(バッファ方式)。

main関数(25〜39行目)

color_index = 0
set_color(color_index)       # 起動時に赤で点灯
print("atom lite ready...")  # PC側のシリアルモニタに表示される
last_state = btn.value()     # ボタンの現在の状態を取得(1=離してる, 0=押してる)
while True:                  # 無限ループ(マイコンは終了しない)
    state = btn.value()
    if last_state == 1 and state == 0:   # 「離してた→押した」の瞬間を検出

これがエッジ検出。押しっぱなしでも「1→0になった瞬間」だけ反応。こうしないと押している間ずっと色が高速で切り替わってしまう。

        color_index = (color_index + 1) % len(COLORS)  # 0→1→2→3→0→... と循環
        set_color(color_index)
        print("color:", color_index)
        time.sleep_ms(50)    # チャタリング防止(50ms待つ)

チャタリング: 物理ボタンは1回押しても接点が微細に跳ねて0/1が数回バタバタする現象。少し待つことで無視する。

    last_state = state       # 今の状態を「前回の状態」として保存
    time.sleep_ms(10)        # 10msごとにループ(CPU負荷軽減)

エントリポイント(42〜43行目)

if __name__ == "__main__":
    main()

avaの public static void main(String[] args) に相当。「このファイルが直接実行された時だけmain()を呼ぶ」という意味。他のファイルから import された場合は実行されない。

実行(1回きり)

仮想環境を作ってツールをインストール

IN: cd c:/Users/penguin/projects/hercules && python -m venv .venv 2>&1

OUT: (Bash completed with no output)

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/pip install -r requirements.txt 2>&1 OUT:

Collecting mpremote==1.24.1 (from -r requirements.txt (line 1))
  Downloading mpremote-1.24.1-py3-none-any.whl.metadata (4.2 kB)
Collecting esptool==4.8.1 (from -r requirements.txt (line 2))
  Downloading esptool-4.8.1.tar.gz (409 kB)
省略
mpremote==1.24.1
esptool==4.8.1

mpremote はシリアル接続経由で、MicroPythonデバイスを操作するユーティリティ:DOC esptool はESP32の販売元であるEspressifが公開しているツールであり、ESP8266とESP32用のブートローダーと通信するためのPythonプログラム:github

ツール類インストール完了。次はAtom LITEが見えているか確認

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/mpremote connect list 2>&1

OUT: COM7 None 0403:6001 FTDI None

これは ポート名 シリアル番号 VID:PID 製造元 製品名 というフォーマットです。

0403:6001 は FTDI社のFT232 というUSB-Serialチップを示すID。 ところが、M5Stackの現行スペックシートではAtom LITEは CH9102 (別メーカー製、VID:1a86)と書かれているのが一般的

MicroPythonファームウェアをダウンロード

IN: cd c:/Users/penguin/projects/hercules && mkdir -p firmware && curl -L -o firmware/ESP32_GENERIC-v1.28.0.bin https://micropython.org/resources/firmware/ESP32_GENERIC-20260406-v1.28.0.bin 2>&1 | tail -5

IN: ls -la c:/Users/penguin/projects/hercules/firmware/ 2>&1

OUT:

total 1724
drwxr-xr-x 1 penguin 197121       0 Apr 17 19:59 .
drwxr-xr-x 1 penguin 197121       0 Apr 17 19:59 ..
-rw-r--r-- 1 penguin 197121 1760192 Apr 17 19:59 ESP32_GENERIC-v1.28.0.bin

ファームウェアDL完了 (約1.7MB)。

Atom LITEのフラッシュを消去

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/esptool --chip esp32 --port COM7 erase_flash 2>&1

OUT:

esptool.py v4.8.1
Serial port COM7
Connecting......
Chip is ESP32-PICO-D4 (revision v1.1)
Features: WiFi, BT, Dual Core, 240MHz, Embedded Flash, VRef calibration in efuse, Coding Scheme None
Crystal is 40MHz
MAC: 14:08:08:55:58:b4
Uploading stub...
Running stub...
Stub running...
Erasing flash (this may take a while)...
Chip erase completed successfully in 6.0s
Hard resetting via RTS pin...

次はMicroPythonファームウェアを焼きます。

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/esptool --chip esp32 --port COM7 write_flash -z 0x1000 firmware/ESP32_GENERIC-v1.28.0.bin 2>&1

OUT:

esptool.py v4.8.1
Serial port COM7
Connecting.....
Chip is ESP32-PICO-D4 (revision v1.1)
Features: WiFi, BT, Dual Core, 240MHz, Embedded Flash, VRef calibration in efuse, Coding Scheme None
Crystal is 40MHz
MAC: 14:08:08:55:58:b4
Uploading stub...
Running stub...
Stub running...
Configuring flash size...
Flash will be erased from 0x00001000 to 0x001aefff...
Compressed 1760192 bytes to 1148677...
Writing at 0x00001000... (1 %)
Writing at 0x000109f5... (2 %)
割愛
Writing at 0x001adf54... (100 %)
Wrote 1760192 bytes (1148677 compressed) at 0x00001000 in 105.1 seconds (effective 134.0 kbit/s)...
Hash of data verified.

Leaving...
Hard resetting via RTS pin...

MicroPythonファームウェア焼き込み成功。

まずMicroPythonが正しく起動しているか確認します。

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/mpremote connect COM7 exec "import sys; print(sys.implementation)" 2>&1

OUT: (name='micropython', version=(1, 28, 0, ''), _machine='Generic ESP32 module with ESP32', _mpy=11014, _build='ESP32_GENERIC',割愛

MicroPython v1.28.0 がAtom LITE上で動作中。

最後にboot.pyとmain.pyを転送します。

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/mpremote connect COM7 fs cp src/boot.py :boot.py 2>&1 && .venv/Scripts/mpremote connect COM7 fs cp src/main.py :main.py 2>&1

OUT:

cp src/boot.py :boot.py
cp src/main.py :main.py

ファイル転送完了。

Atom LITEをリセットして起動させます。

IN: cd c:/Users/penguin/projects/hercules && .venv/Scripts/mpremote connect COM7 reset 2>&1 OUT: (Bash completed with no output)

解説

micropython.org サイト

  • MicroPython公式の ESP32汎用ボード向けファームウェア配布ページ。
  • https://micropython.org/download/ はボードのカタログページ。

  • 基本はベンダー/メーカー別に並んでいる

  • 上部にフィルタがあって、絞り込みできる:

    • Port(MCUアーキテクチャ別、例: ESP32, RP2, STM32)
    • Feature(機能別、例: BLE, Wi-Fi, PSRAM)
    • Vendor(メーカー別、例: Espressif, Adafruit, Arduino)
    • MCU(チップ型番別)
  • 基板画像がズラッと並んでるのは市販されている個別ボード(Adafruit Feather, Arduino Nano ESP32, Raspberry Pi Pico等)それぞれに最適化されたビルドが用意されているからです。

自分に合うMicroPythonの選び方

① 自分のMCUは何か?
    ↓
② そのMCU用に「自分の製品名」がリストにあるか?
    ↓
    あり → 個別ビルドを使う
    なし → 「XXXX_GENERIC」を使う

MicroPython - Python for microcontrollers

ちなみに「WROOM」は何かというと:

  • ESP-WROOM-32 はEspressifが作るモジュール(チップ + Flash + アンテナのパッケージ)の名前
  • ESP32の標準的なモジュール形態なので「ESP32 = WROOM」という認識が業界に広まっている
  • だからMicroPythonの表示名も「ESP32 / WROOM」と併記されている
  • Atom LITEは ESP-WROOM-32 ではなく ESP32-PICO-D4(より小型統合版)を使っていますが、チップファミリーは同じESP32なので ESP32_GENERIC が正解で、選択判断は変わりません。

MCUとは

MCU = Microcontroller Unit(マイクロコントローラ・ユニット)= マイコン

「1つのチップにCPU + メモリ + I/O機能を全部詰め込んだ小型コンピュータ」のことです。

  • CPU / MPU / MCU の違い
略称 正式名 中身
CPU Central Processing Unit 計算する頭脳のみ i7, Ryzen, Apple M3
MPU Micro Processing Unit CPU + ちょっとしたI/O。外付けでメモリ等が必要 ARM Cortex-A系
MCU Micro Controller Unit CPU + RAM + Flash + GPIO等を全部1チップに統合 ESP32, Arduino UNO の AVR
  • イメージで比較
[PC(CPUベース)]
 ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐
 │ CPU  │  │ RAM  │  │ SSD  │  │ GPU  │
 └──────┘  └──────┘  └──────┘  └──────┘
   ↑ 部品ごとに別チップ。基板でつなぐ

[Atom LITE(MCUベース)]
 ┌─────────────────────┐
 │   ESP32-PICO-D4     │
 │  ┌────┐ ┌────┐      │
 │  │CPU │ │RAM │      │ ← 全部1チップ内
 │  ├────┤ ├────┤      │
 │  │Flash│ │GPIO│      │
 │  └────┘ └────┘      │
 └─────────────────────┘

なぜMCUを使うか - 小さい(指の爪サイズの基板でも動く) - 安い(数百円〜) - 省電力(ボタン電池でも動かせる) - すぐ動く(OS不要、電源ONですぐ起動) - GPIO直結(センサーやLEDを直接繋げる)

MicroPythonのダウンロードページで「Filter by MCU」というフィルタがありましたよね。あれは「どのマイコンチップ向けか」で絞り込むためのものです:

どうやったらMCUがわかるか

PS C:\Users\penguin\projects\hercules> .venv/Scripts/esptool --port COM7 chip_id 2>&1
esptool.py v4.8.1
Serial port COM7
Connecting....
Detecting chip type... Unsupported detection protocol, switching and trying again...
Connecting.....
Detecting chip type... ESP32
Chip is ESP32-PICO-D4 (revision v1.1)
Features: WiFi, BT, Dual Core, 240MHz, Embedded Flash, VRef calibration in efuse, Coding Scheme None
Crystal is 40MHz
MAC: <内緒>
Uploading stub...
Running stub...
Stub running...
Warning: ESP32 has no Chip ID. Reading MAC instead.
MAC: <内緒>
Hard resetting via RTS pin...
項目 何がわかるか
① ESP32 MCUファミリー = ダウンロードページで ESP32_GENERIC を選ぶ判断材料
② ESP32-PICO-D4 チップの正確な型番。Flash 4MB 内蔵モデル
③ Features WiFi, BT あり、Dual Core(2コア)、240MHz 駆動
④ 40MHz 基板に乗ってる水晶発振子の周波数
⑤ MAC このチップ固有のID(Wi-Fi接続時に使う)

ボーレートとは

MicroPythonを書き込むときに cd c:/Users/penguin/projects/hercules && .venv/Scripts/esptool --chip esp32 --port COM7 --baud 460800 write_flash -z 0x1000 firmware/ESP32_GENERIC-v1.28.0.bin 2>&1 と、--baud 460800 をつけて実行したら失敗。

  • ボーレート (baud rate) = シリアル通信の速度。1秒間に何ビット送受信するか。
読み方 速度感
460800 46万ボー 高速
115200 11万ボー 標準(デフォルト)

返ってきたエラー:

A fatal error occurred: Unable to verify flash chip connection 
(No serial data received.)
  • 訳すと「シリアルからデータが返ってこない」。高速で送ろうとしたら通信が破綻した状態。
  • なぜ失敗したか: このAtom LITEのFTDIチップ(または今繋いでいるUSBケーブル)が460800の高速通信に耐えられなかった。ESP32本体は920000でも通信可能だが、ボトルネックはたいていUSB-Serial変換チップ側。

ESP32_GENERIC-v1.28.0.bin

部分 意味
ESP32_GENERIC 対象ボード:汎用ESP32向け(特定製品専用じゃない)
v1.28.0 MicroPythonのバージョン
.bin バイナリファイル(機械語)

esptool write_flash -z 0x1000 file.bin の意味

  • Flash = 番地(アドレス)付きの広い記憶領域。ESP32の場合 4MB=約400万バイトあって、各バイトに 0x000000 〜 0x400000 という住所が振られています。
Flashの番地地図(ESP32の標準レイアウト)

アドレス      サイズ   中身
0x000000  ┐
 〜       │  4KB   (予約領域、空き)
0x000FFF  ┘
0x001000  ┐
 〜       │  28KB  ブートローダ
0x007FFF  ┘
0x008000  ┐
 〜       │  4KB   パーティションテーブル
0x008FFF  ┘
0x00E000  ┐
 〜       │  8KB   OTAデータ
0x00FFFF  ┘
0x010000  ┐
 〜       │  約2MB アプリ領域 (MicroPython本体)
0x200000  ┘
0x200000  ┐
 〜       │  約2MB ファイルシステム領域 (main.py等)
0x3FFFFF  ┘

flash全体の上書きの実態

  • .bin は ブートローダ〜アプリ領域まで(約1.7MB分)をカバー
  • ファイルシステム領域(0x200000以降)は .bin に含まれていないので焼き込みでは触られない
  • ただし今回は事前に erase_flash でFlash全体を0クリアしていたので、結果的にファイルシステムも空の状態になった
  • MicroPythonは初回起動時に「ファイルシステム領域が空だ」と気づいて自動で初期化してくれる
  • その初期化された領域に、後から mpremote fs cp で boot.py と main.py を置いた

なぜ 0x1000 という固定値なのか?

  • ESP32のブートROM(チップに焼き込まれた最初期起動コード)が 「電源ON → 0x1000番地のブートローダを読みに行く」 という仕様で固定されているため。つまりハードウェア側の決まり事で、勝手に変えられません。

チップとは?

チップ = IC(集積回路)のこと。小さな黒い部品全般を指す広い言葉

CPUだけが「チップ」ではなく、電子基板に載っている小さな黒い四角の部品は基本すべてチップ と呼ばれます。

基板の中には複数のチップがある

[Atom LITE 基板]
 ┌────────────────────────────────┐
 │                                 │
 │  [ESP32-PICO-D4]  ← メインチップ (CPU+Wi-Fi等)
 │                                 │
 │  [FTDI FT232]    ← USB-Serial変換チップ
 │                                 │
 │  [SK6812]        ← RGB LEDチップ
 │                                 │
 │  [AMS1117 等]    ← 電圧レギュレータチップ
 │                                 │
 └──[USB-C コネクタ]───────────────┘
         ↑
         ここから [USBケーブル] が生える

チップを作っている会社は?

質問 答え
ESPチップを作る会社は? Espressif 1社のみ
ESPモジュールを作る会社は? Espressif、Ai-Thinker など複数
ESP搭載ボードを作る会社は? M5Stack、Adafruit、SparkFun など多数
  • チップ = 基板に載ってる黒い小さな部品(IC)全般
  • ケーブル = 信号を運ぶ線(チップではない)
  • メインチップ / マイコンチップ = その基板の中心的な頭脳になるチップ(これがCPU/MCU)
[Espressif]
    ↓ チップを製造
ESP32-PICO-D4(ESP32本体 + Flash 4MB を1チップ統合)
    ↓ M5Stack社が基板に載せる
[M5Stack製] Atom LITE 基板
(USB-Cコネクタ、FTDI、LED、ボタン等を周りに配置)

USBケーブルにはチップは入っていない

  • USBケーブル = 銅線が4〜5本入ってるだけのただのケーブル(チップは無い)
  • USB-Serial変換チップ = Atom LITEの基板上にハンダ付けされてる部品
[PC] ══════USBケーブル══════ [USB-Cコネクタ] → [FTDIチップ] → [ESP32]
         ↑                                ↑
      ただの銅線                   基板の上に載ってる
      (チップなし)                  部品としてのチップ

なぜUSB-Serialチップが必要か

ESP32はシリアル通信(古くからある単純な通信方式)しかできません。一方PCとの接続はUSB(複雑なプロトコル)。この変換を担うのがUSB-Serialチップです:

 [PC] ──USB信号──→ [変換チップ] ──シリアル信号──→ [ESP32]
       (複雑)                    (単純)
  • ESP32自身はUSBを直接理解できないので、間に通訳役としてFTDIやCH9102が必要、というわけです。 (ちなみにESP32-S3やESP32-C3など新しい世代はUSBを直接扱えるので変換チップ不要になっています)

全部「チップ」です。「チップ=CPU」ではなく、「チップ=小さな電子部品全般」という感覚が正確。

役割 チップの例
CPU/マイコン ESP32, ARM Cortex, Intel Core i7
メモリ DDR5 RAMチップ、Flashチップ
USB-Serial変換 FTDI FT232, CH9102, CP2104
電圧レギュレータ AMS1117, LDO
LED SK6812, WS2812
センサー BME280(温湿度), MPU6050(加速度)

PinMapからGPIOピンの番号を判別する

左側のピン                   [Atom LITE基板]                  右側のピン
─────────                                                    ─────────
3V3   ← 3.3V電源出力                                         G21  → SCL(I2C用)
G22   → 汎用GPIO                                              G25  → SDA / DAC
G19   → MOSI(SPI用)                                          5V   → 5V電源出力
G23   → CLK(SPI用)                                           GND  → グランド
G33   → MISO / ADC(SPI/アナログ入力)

底面のGroveコネクタ:GND / 5V / G26 (DAC) / G32 (ADC)

基板中央の小さい文字 Neo=G27, Btn=G39 がポイント。「NeoPixel LED は G27、ボタンは G39」という意味で、これが今回のコードの根拠です:

LED_PIN = 27 # ← Neo=G27 から BTN_PIN = 39 # ← Btn=G39 から

上の図 「G25 に DAC と SDA の両方が書いてある」=「このピンはアナログ出力にもI2C通信のSDAにも使える」という意味。1本のピンが複数機能を兼ねているのが特徴。

簡易回答

質問 答え
Gとは? GPIOの略
5つしかないの? いいえ、Atom LITEで11本、ESP32全体だと34本
GPIOとは? コードで自由に使える入出力端子
ADC アナログ電圧 → デジタル数値(センサー読み取り用)
DAC デジタル数値 → アナログ電圧(音声等の出力用)
SPI 高速シリアル通信(4本線)
I2C 省線シリアル通信(2本線)

ESP32-PICO-D4チップ自体は 34本のGPIO を持っていますが、Atom LITEはサイズの都合で一部しかピンとして引き出していません。

GPIO とは?

GPIO = General Purpose Input Output(汎用入出力)

「好きに使える入出力端子」のこと。プログラムから:

  • 出力モード にすると「電圧を出す/出さない」を制御できる(LED光らせる等)
  • 入力モード にすると「外から電圧が来てるか?」を読み取れる(ボタン状態を見る等)
[ESP32] ──GPIO27──→ [LED]      (出力:1=光る、0=消える)
[ESP32] ←─GPIO39── [ボタン]    (入力:1=離してる、0=押してる)

Pin.IN / Pin.OUT の指定はこれの切り替えでした。

デジタル信号 vs アナログ信号

GPIOは基本的に デジタル(0か1かの2値)しか扱えません:*

デジタル:  ─┐ ┌──┐  ┌──
            └─┘  └──┘     ← 0と1だけ

アナログ: ~~~~~~~~  ← 滑らかに変化(電圧0〜3.3V)

しかし現実のセンサーは多くがアナログ(温度、明るさ、音量など連続値)。そこで以下の機能が用意されています:

ADC, DAC, SPI, I2C, PWM とは?

これらは「GPIOに追加機能を持たせたもの」です。チップ内部で特別な回路と繋がっているピン限定。

ADC

ADC = Analog to Digital Converter(アナログ → デジタル変換)

外部から来るアナログ電圧を、デジタル数値で読み取る機能。

  • 外部電圧 1.65V → ADC変換 → コードでは「2048」(12bit中央値)
  • 用途:温度センサー、明るさセンサー、可変抵抗などの読み取り
from machine import ADC, Pin
adc = ADC(Pin(33))
value = adc.read()  # 0〜4095の数値

ADCで「できること」一覧 - 可変抵抗(つまみ)の位置を読む - 光センサー、温度センサー、湿度センサー - マイク(音量を数値化) - バッテリー残量の電圧監視 - 圧力センサー、土壌湿度センサー - ジョイスティックの傾き

「外界の連続的な変化を数値で読む」用途全般。

DAC

DAC = Digital to Analog Converter(デジタル → アナログ変換)

ADCの逆。コードで指定した数値を、滑らかなアナログ電圧として出力する機能。

  • 用途:音声出力、滑らかなLED調光

  • ESP32ではG25とG26のみがDAC対応。これは特殊な回路がそのピンにしか繋がっていないから。

DACで「できること」一覧 - スピーカー/イヤホンに音声出力 - センサーへの参照電圧供給 - 滑らかなアナログ調光 - 簡易関数発生器(テスト用) - アナログ機器との連携(古いオーディオ機器等) - 「コードで作った値を物理電圧として外に出す」用途。

ただしESP32のDACは8bit (0〜255) と精度が低めなので、本格的な音楽用途には外付けDACチップを使うのが一般的。

SPI

SPI = Serial Peripheral Interface

高速なシリアル通信規格。複数のセンサー/機器とPCのUSBのような感覚で繋ぐ。

  • 用途:液晶ディスプレイ、SDカード、高速センサー

4本の線が必要:

  • MOSI (Master Out Slave In) — 送信用
  • MISO (Master In Slave Out) — 受信用
  • CLK (Clock) — タイミング合わせ用
  • CS (Chip Select) — どの機器と話すか選択
[ESP32] ─MOSI/MISO/CLK/CS─→ [SDカード]
                                                     └→ [液晶]
                                                     └→ [...]

I2C

I2C = Inter-Integrated Circuit

SPIより遅いが線が少ない通信規格。2本だけで複数の機器と通信できる: - SDA (Serial Data) — データ - SCL (Serial Clock) — タイミング

  • 用途:温湿度センサー、加速度センサー、小型ディスプレイ等
各機器にアドレスが振られていて、それで通話相手を区別する。

[ESP32] ─SDA/SCL─→ [温度センサー] (アドレス0x76)
                              └→ [加速度センサー] (アドレス0x68)

I2Cは省線で多機能なので、組み込みの世界で一番よく使われる通信。

PWM

PWM = Pulse Width Modulation(パルス幅変調)

これはPinMap表にはないけど重要。デジタル信号で擬似的にアナログっぽい出力をする技術:

  • 用途:LED調光、モーター速度制御、サーボモーター制御。ほぼ全GPIOで使えます。
0_____________  ┐ 50%デューティ → LEDが半分の明るさに見える
0─┐  ┌─┐  ┌─┐   ┌─  
    └─┘  └─┘   └─┘  

ピンの兼用問題:G25はDACかSDAか?

コードで決めます。ESP32の内部はこうなっています:

[G25 ピン]
   │
   ├── DAC回路に接続 ─┐
   ├── I2C回路に接続   ─┤  ← どれを「有効」にするかはプログラム次第
   ├── ADC回路に接続 ─┤
   ├── GPIO回路に接続─┘
   └── (他の機能...)

1本の物理ピンに内部で複数の回路が繋がっている。コードで「どの回路を使うか」を指定すると、その用途で動く。

// DAC として使う:

from machine import DAC, Pin
dac = DAC(Pin(25))    # ← この瞬間、G25はDACモードになる
dac.write(128)
// I2C SDAとして使う:
from machine import I2C, Pin
i2c = I2C(0, scl=Pin(21), sda=Pin(25))  # ← G25をSDAとして指定
i2c.scan()
// 普通のGPIOとして使う:
from machine import Pin
g25 = Pin(25, Pin.OUT)  # ← 出力モードのGPIOになる
g25.value(1)

ピンの設計の仕方

制約一覧:ESP32では「任意GPIOで使える機能」と「特定ピン限定の機能」があります

機能 ピン制約 理由
GPIO ほぼ全ピン 全ピンに基本回路あり
PWM ほぼ全ピン ソフトで生成
ADC 一部のピンのみ アナログ→デジタル回路が一部にしかない
DAC G25, G26 のみ DAC回路が物理的にこの2本にしか繋がってない
I2C 任意ピン(割当可) I/O Matrixで自由に割当可
SPI 任意ピン(高速時は推奨ピンあり) 同上

つまり:

  • DACは選択肢が極端に狭い(2本のみ、変えられない)
  • I2Cは柔軟(どのピンをSDAにしても良い)

なので競合した時はDACが優先確保、I2CはDACで使ってないピンに逃がすのが定石。

ヘラクレスオオカブト生育環境を最適化する(1)~M5Stack ATOM LITEをMicroPythonで構築しよう~

使うもの

ヘラクレスの飼育環境は温度20〜25度が最適。乾燥注意。

開発環境の前提知識

M5Stackに搭載されているマイコンESP32で、MicroPythonを実行させることができます。

Python の「実行環境」とは

言語 ソース 実行するもの
Java .java → .class(バイトコード) JVM
JavaScript .js V8エンジン等
Python .py Pythonインタプリタ

Pythonインタプリタ=JVMやV8に相当するものです。

ここが重要:

Pythonインタプリタは「1種類」ではありません。実装が複数あります。

実装 動く場所 特徴
CPython Windows/Mac/Linux 一般的に「Python」と言ったらこれ
PyPy 同上 高速化版
Jython JVM上 JVMで動くPython
MicroPython マイコン 軽量化版、今回使うのはこれ

「JVMがどこで動くか」でJavaの実行場所が決まるのと同じで、「Pythonインタプリタがどこにインストールされているか」でPythonの動く場所が決まります。

PCには CPython が入っている(python コマンド)
Atom LITE には MicroPython が入る(焼き込む)

MicroPython と Python の違い

CPython(通常のPython) MicroPython
動く場所 PC/サーバー(OS前提) マイコン(OS無し)
サイズ 数十MB 数百KB
文法 Python3 Python3サブセット
標準ライブラリ フル 削減版 + ハード制御用追加

文法はほぼ同じですが、CPython用に作られたライブラリ(numpy、Flask等)は動きません。 マイコン用に作り直された別実装だからです。

MicroPythonを使う場合はpipインストールしたライブラリは使えない

ハード制御に必要なライブラリは MicroPythonファームウェアに組み込み済み:

  • machine — GPIO/I2C/SPIなどハード制御(MicroPython組み込み)
  • neopixel — WS2812/SK6812系LED制御(MicroPython組み込み)
  • time — MicroPython組み込み
  • esp , gc(boot.py) — MicroPython組み込み

たとえば neopixel を使ってATOMのボタンの真ん中部分を光らせることができます!

ライブラリを使おう!とPCのターミナルで pip install 〇〇 を実行しても、それは自分のパソコンにダウンロードして保存するだけ。 USBケーブルで繋がっている先のESP32には一切影響を与えません。

じゃあ、実行環境であるESP32上でライブラリを使いたい場合はどうするのか? PCでダウンロードした .py ファイルを、ドラッグ&ドロップ感覚でESP32の内部ストレージに直接コピーする。

実は、今回使うENV Ⅲセンサを動かすための専用プログラムはMicroPythonには入っていないので、ネットからセンサ用の .py ファイルをダウンロードしてきて、フラッシュメモリ(後述の領域B)にコピーしてあげる必要があります。

Atom LITE の中身

┌─────────────────────────────────────┐
│          Atom LITE 基板              │
│                                      │
│  ┌──────────────────┐                │
│  │ ESP32-PICO-D4    │ ← メインチップ  │
│  │ ├ CPU (2コア)    │                │
│  │ ├ Wi-Fi/BLE      │                │
│  │ ├ RAM (520KB)    │ ← 揮発性        │
│  │ └ Flash (4MB内蔵)│ ← 不揮発性      │
│  └──────────────────┘                │
│                                      │
│  ・RGB LED (SK6812) × 1              │
│  ・IR LED × 1                        │
│  ・ボタン × 1                         │
│  ・USB-Serial変換チップ (FTDI)        │
│  ・USB-Cコネクタ                      │
│  ・Groveコネクタ (拡張用)             │
│  ・アンテナ                           │
└─────────────────────────────────────┘

ESP32-PICO-D4 は1チップの中にCPU・RAM・Flashが統合されているのが特徴。だからAtom LITEは小さく作れる。

RAMとFlashの違い

RAM Flash
性質 揮発性(電源切ると消える) 不揮発性(電源切っても残る)
用途 実行中のデータ プログラム保存
例えるなら PCのメモリ PCのSSD

Flashの中身(構造)

[Flash 4MB]
├── ブートローダ       ← 電源ON時に最初に動く小さいプログラム
├── パーティションテーブル ← 「どこに何があるか」の地図
├── アプリ領域          ← ここにMicroPython.binを焼く
├── ファイルシステム    ← ここにmain.py/boot.pyを置く
└── NVS (設定保存領域)  ← Wi-Fi情報など

どこまでを「ファームウェア」というのか?

ファームウェア : デバイスを動かすソフト(OS相当 or それより低レベル)のこと。文脈に応じてスコープが変わる。

今回:

MicroPython.bin ( ESP32_GENERIC-v1.28.0.bin ) 自体が 「ESP32をMicroPythonデバイスに変身させるソフトウェア一式」なので、それを「ファームウェア」と呼ぶ。

広い意味: Flashに焼かれている全部 = ファームウェア
狭い意味: アプリ領域の中身(MicroPython本体)= ファームウェア
  • ESP32_GENERIC-v1.28.0.bin の中には ブートローダ + パーティションテーブル + MicroPython本体 が入っている
  • これ1ファイルが「MicroPythonファームウェア」
  • 焼くときのアドレス 0x1000 は「ブートローダから始まる位置」なので、結果的にFlash全体がまとめて上書きされる
【Atom LITE(物理チップ=ESP32)】
 └─ Flash
     ├─ ブートローダ + パーティションテーブル + アプリ領域 ← ESP32_GENERIC-v1.28.0.bin をここに焼く
     │   ├─ MicroPythonインタプリタ
     │   └─ 組み込みモジュール (machine, neopixel等)
     │
     └─ ファイルシステム ← main.py/boot.py はここに置く
         ├─ boot.py
         └─ main.py

開発サイクル

A. 一度きりの準備

  1. PCで MicroPython の .bin をダウンロード
  2. USBケーブル経由で、ESP32のフラッシュメモリの領域Aに .bin を書き込む
  3. これで Atom LITE は「Pythonを解釈できるデバイス」になる

B. 毎回の開発サイクル

  1. PCで main.py を書く/直す
  2. USBケーブル経由で、main.py をフラッシュメモリの領域B(ファイルシステム)にコピー
  3. Atom LITE をリセット
  4. 起動時、領域AのMicroPythonインタプリタが自動起動
  5. インタプリタが領域Bの boot.py を読んで実行
  6. 続いて main.py を読んで実行
  7. print() の出力はUSB経由でPC側に送られて見える

C++ との違い:

C++ではステップ1で毎回「コンパイル→全部焼く」が発生。 MicroPythonではステップ1はファイル転送だけ(数百msで終わる)。

おしまい

次から実装・デバッグなどやっていきます。 おそらくENV Ⅲセンサを動かすためのライブラリ(?)が今回の山場になる予感で。

Spring Boot 2.4 から 4.0 にジャンプアップした話

2025年も、いよいよ終わろうとしています。
大掃除ということで、昔に作ったプロジェクトの開発基盤をバージョンアップしました。

コンポーネント 移行前のバージョン 移行後のバージョン
Java 8 17
Spring Boot 2.4.5 4.0.1
JUnit Jupiter 5.7.1 6.0.1
Gradle 7.0 9.2.1

build.gradleをGradle 9の形式に書き換えれば動くだろう、ぐらいに思っていたんですが、Spring Frameworkで色々と変更が必要だったので、ここに記録します。

javax から jakarta への移行作業

エラーメッセージを見た瞬間、「あー、それか」となりました。
Java EEJakarta EE に移管されたことによるパッケージ名の変更が必要となりました。

変更前

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

変更後

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

Spring Framework Test 関係の移行作業

いくつかのJavaクラスのimport文でインポートするクラスが見つからないエラーが発生しました。
具体的には以下のクラスが見つからなかったです。

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.mock.mockito.MockBean;

import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

これらのクラスはパッケージ名やクラス名が変わっただけではなく、依存ライブラリも変わっています。
以前は、spring-boot-starter-test に含まれていましたが、Spring Boot 4.0 では、いくつかのライブラリに分割されたため、必要なライブラリを依存関係に追加する必要があります。

移行後では、以下のSpring Framework Testを依存関係に含めています。

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation 'org.springframework.boot:spring-boot-starter-jdbc-test'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'

ライブラリだけではなく、パッケージ名や、一部のクラス名が変わっているので、import文を以下のように修正しました。

import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;

実行するとWebリクエストでフォームクラスのエラーが発生

Webアプリを実行すると、以下のエラーが発生しました。これは想定外のものでした。

jakarta.servlet.ServletException: Request processing failed: java.lang.IllegalStateException: Cannot resolve parameter names for constructor public penguin.web.controller.form.ProfileForm(java.lang.String,java.time.LocalDate,java.lang.Long)

これは、Spring Boot 4.0 (Spring Framework 7.0) へのアップデートに伴うコンパイラの引数保持に関する厳格化」が原因です。

Java 8から存在する仕様ですが、Javaコンパイラはデフォルトでは「メソッドやコンストラクタの引数名(name, birthday など)」をコンパイル後のクラスファイルに保持しません。

Spring Boot 4.0 の基盤である Spring Framework 7.0 では、これまで引数名を推測するために使われていた古い手法(LocalVariableTable)への依存が廃止されました。そのため、コンパイル時に -parameters オプションを明示的に付けない限り、Springはフォームクラス(RecordやDTO)のコンストラクタに値をマッピングできなくなりました。

build.gradle に以下の記述を追加します。これにより、Javaコンパイラが引数名を保持したままクラスファイルを生成するようになります。

tasks.withType(JavaCompile).configureEach {
    options.compilerArgs << '-parameters'
}

合わせて、引数無しのコンストラクタが未定義であれば、定義します。

なぜ、以前のSpring Frameworkでは発生しなかったのか

以前の Spring はバイトコードを解析して引数名を推測する「標準的でない手法」を持っていましたが、Javaの進化(Record型の導入など)に伴い、「標準の -parameters フラグを使うべき」という方針に完全に切り替わりました。

Gradle-Eclipse プラグインで自動設定する

チーム開発などで他のメンバーもEclipseを使う場合、build.gradle に以下の記述を追加して [Gradle] -> [Refresh Gradle Project] を実行すると、上記の設定が自動的にEclipseへ適用されます。

plugins {
    id 'eclipse' // eclipseプラグインを追加
}

eclipse {
    jdt {
        file {
            withProperties { properties ->
                // method parametersの保持を有効化する
                properties['org.eclipse.jdt.core.compiler.codegen.methodParameters'] = 'generate'
            }
        }
    }
}
根本的な解決方法

Spring Boot 4.0 / Java 21+ で推奨される根本的な解決方法、かつコードを綺麗にするには、フォームBeanを record で定義することを強くお勧めします。Java Recordであれば、コンパイラの設定がより確実に反映されやすくなります。

Spring Boot CLIでコマンドラインアプリを作る

Spring Bootではコマンドラインのアプリケーションを作るためのインターフェース「CommandLineRunner」が用意されています。
このインターフェースを使って、Spring Bootのコマンドラインアプリを作成してみます。

build.gradle

spring-boot-starterを依存関係に指定するだけです。

plugins {
    id 'org.springframework.boot' version '4.0.1'
    id 'java-library'
}

java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

dependencies {
    implementation(enforcedPlatform("org.springframework.boot:spring-boot-dependencies:4.0.1"))
    implementation 'org.springframework.boot:spring-boot-starter'
}

Javaクラス

CommandLineRunnerインターフェースをimplementsして、「run」メソッドを実装するだけです。

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Override
    public void run(String... args) {
        System.out.println("Hello World.");
    }
}