はじめに
以前、SwitchBotの温湿度計のデータをAPI経由でZabbixに取り込んでみたという記事で、市販のSwitchBot温湿度計をクラウドAPI経由でZabbixに取り込みました。今回はその発展として、ESP32とSHT41センサーを使って温湿度センサーを自作し、MQTT経由でZabbixに送るところまでやってみました。
自分は電子工作は初心者で、はんだ付けもほとんどやったことがなく、I2CもMQTTも「聞いたことないし、なにそれ!」レベルからのスタートでした。市販センサーのAPI連携と違って、今回はハードウェアの配線からセンサーの読み取り、Wi-Fi/MQTT通信まで全部自分で組む必要があったので、正直かなり難しかったです。詰まった箇所も多かったので、同じように「マイコンからZabbixに値を送ってみたい」と思っている初心者の方の参考になれば嬉しいです。
最終的なデータの流れ
ESP32(SHT41読み取り) → Wi-Fi → MQTT publish → Mosquitto(Docker)
→ Python (MQTT subscribe / pyzabbix) → Zabbix Server
図の意味を先に簡単に説明しておきます。ESP32というマイコンにSHT41というセンサーを繋いで温度と湿度を測り、その値をWi-Fi経由で「MQTT」というプロトコルでサーバーに送ります。サーバーではMosquittoというソフトがMQTTのメッセージを受け取り、Pythonのプログラムがそれを拾ってZabbixに転送する、という構成です。それぞれの用語はあとで登場したときに補足します。
使ったもの
- ESP32-DevKitC-32E(ESP32-WROOM-32E搭載のマイコンボード)
- SHT41温湿度センサー(秋月電子)
- ブレッドボード(EIC-801)
- コネクター付ケーブル 20cm 40P オスオス
- Arduino IDE(ESP32にプログラムを書き込むための開発環境)
- Mosquitto(MQTTブローカー、Docker)
- Python(paho-mqtt、py-zabbix)
- Zabbix 7.0 LTS(Docker Compose、既存構築済み)
ESP32はWi-Fi機能内蔵の小さなマイコンボードで、Arduino IDEでプログラムを書ける手軽さから電子工作の入門にもよく使われています。SHT41は温度と湿度をI2Cという方式でマイコンに渡してくれるセンサーです。
ステップ1: SHT41の温湿度読み取り
まずはESP32とSHT41を配線して、シリアルモニタに温湿度が表示されるところまで進めます。ここが動かないと先に進めません。
I2Cの配線について
SHT41は「I2C」という2本の信号線(SDA/SCL)でマイコンと通信するタイプのセンサーです。マイコン側の「SDA」ピンをセンサーの「SDA」ピンに、「SCL」を「SCL」に繋げばいい、というシンプルな話です。
- ESP32側のI2CピンはArduino-ESP32では
Wire.begin(SDA, SCL)で自由に指定できます。慣例的にはGPIO21をSDA、GPIO22をSCLとして使う例が多いですが、今回は後述のブレッドボードの都合でGPIO26=SDA、GPIO25=SCLを使いました。 - SHT41のピンは、購入した秋月電子のモジュールでは、向かって左から
+3V3 / VIN / GND、右側にOE / SCL / SDAの並びでした。ピン配置や各ピンの役割はモジュールによって違うので、必ず自分が買ったモジュールの販売ページや基板シルクを確認してください。特に電源ピンは、VINの許容電圧や3.3Vピンの入出力方向を確認してから配線しないと、最悪センサーが壊れる可能性があります。
ブレッドボードの幅が足りない問題
ESP32-DevKitC-32Eをブレッドボード(EIC-801)に挿そうとしたところ、ボードの幅が広すぎて、片側のピンしかブレッドボードの穴に入らないことが分かりました。普通ならブレッドボードにマイコンを挿して、両側から配線を引き出すのですが、それができません。
今回は片側のピン列だけを使用する形で妥協しました。挿した側のピンから信号を取り出す必要があるので、I2Cは慣例のGPIO21/22ではなく、その列にあるGPIO25/GPIO26を使う形にした、というのが先ほどの配線の理由です。ESP32-DevKitC-32Eを使う場合は、購入前にブレッドボードの幅を確認しておくのがおすすめです。
テストコード
Arduino IDEのライブラリマネージャで 「Sensirion I2C SHT4x」(作者: Sensirion)をインストールします。依存ライブラリのSensirion Coreも一緒に入れます。
#include <Wire.h>
#include <SensirionI2cSht4x.h>
SensirionI2cSht4x sensor;
void setup() {
Serial.begin(115200);
delay(1000);
Wire.begin(26, 25); // SDA=GPIO26, SCL=GPIO25 使用するピンに合わせて設定
sensor.begin(Wire, SHT40_I2C_ADDR_44);
uint32_t serialNumber = 0;
int16_t error = sensor.serialNumber(serialNumber);
if (error != 0) {
Serial.println("Error: serialNumber() failed");
} else {
Serial.print("SHT4x serial number: ");
Serial.println(serialNumber);
}
}
void loop() {
float temperature = 0.0;
float humidity = 0.0;
int16_t error = sensor.measureHighPrecision(temperature, humidity);
if (error != 0) {
Serial.println("Error: measurement failed");
} else {
Serial.print("Temperature: ");
Serial.print(temperature);
Serial.print(" C\tHumidity: ");
Serial.print(humidity);
Serial.println(" %");
}
delay(2000);
}
一つ気になったのが SHT40_I2C_ADDR_44 という定数名です。「SHT41なのにSHT40?」と思うのですが、これはSHT4xシリーズの多くで共通のI2Cアドレス 0x44 を表しているだけで、SHT41でもそのまま使えます。
シリアルモニタ(115200bps)に2秒ごとに温湿度が表示されれば、ハードウェアとセンサー読み取りの土台は完成です。
SHT4x serial number: 1234567890
Temperature: 24.14 C Humidity: 52.60 %
ここまで来ると、初心者的にはかなり達成感があります。「自分で配線したセンサーから、自分で読める値が出てきた」瞬間です。
ステップ2: MosquittoをDockerで構築
次に、ESP32が送ってきたデータを受け止める側を用意します。今回はMQTTというプロトコルを使うので、MQTTブローカーというソフトを立てます。
そもそもMQTTとは
MQTTは、IoT機器同士が軽量にメッセージをやり取りするためのプロトコルです。特徴的なのは「publish / subscribe」というやり方で、
- publish(発行): 送信側は「トピック」という名前付きの箱にメッセージを投げ込む
- subscribe(購読): 受信側は自分が興味あるトピックを購読しておき、そこにメッセージが来ると通知される
という仕組みです。送信側と受信側は互いのIPアドレスを知らなくてよく、間に立つ「ブローカー」(今回のMosquitto)が仲介してくれます。今回のESP32は「sensor/room1/temperature」というトピックに温度を投げ、後述するPythonがそれを購読する形になります。
Mosquittoの構築
自宅ラボの既存規則(~/docker/<service-name>/ディレクトリ、shared_networkという共通Dockerネットワーク)に合わせて構築します。
mkdir -p ~/docker/mosquitto/config
mkdir -p ~/docker/mosquitto/data
mkdir -p ~/docker/mosquitto/log
cd ~/docker/mosquitto
設定ファイルを作成します。
nano ~/docker/mosquitto/config/mosquitto.conf
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
log_dest stdout
allow_anonymous true は認証なしで接続を許可する設定です。ホームラボ内部での動作確認用としてまずはこれで進めますが、この状態では同一LAN上の任意の端末が任意のトピックを購読・送信できます。ホストの1883番ポートも公開しているため、1883番を外部公開しないこと、信頼できない端末が混在するLANでは認証・ACL・TLSを設定することを強く推奨します。本格運用時にはパスワードファイル(mosquitto_passwd)による認証を追加する想定です。
docker-compose.yml は以下の内容です。
services:
mosquitto:
image: eclipse-mosquitto:2
container_name: mosquitto
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ./config/mosquitto.conf:/mosquitto/config/mosquitto.conf
- ./data:/mosquitto/data
- ./log:/mosquitto/log
networks:
- shared_network
networks:
shared_network:
external: true
起動前に docker compose config で変数展開に問題がないかを確認してから起動します。
docker compose config
docker compose up -d
docker compose ps
docker compose logs mosquitto
ブローカー単体で動作確認
いきなりESP32を繋ぐ前に、「Mosquittoが本当にpublish / subscribeを処理できるか」だけを確認しておくと、後で切り分けが楽です。ESP32が繋がらないときに「ブローカーが悪いのか、ESP32が悪いのか」を分けて考えられます。
Mosquittoコンテナに mosquitto_pub / mosquitto_sub のCLIが含まれている場合は、以下のコマンドで疎通確認ができます(含まれていない場合は、別ホストに mosquitto-clients パッケージを入れて実行しても同じ確認ができます)。
# ターミナルA(購読側)
docker exec -it mosquitto mosquitto_sub -h localhost -t "test/topic"
# ターミナルB(送信側)
docker exec -it mosquitto mosquitto_pub -h localhost -t "test/topic" -m "hello"
ターミナルBで送信した瞬間にターミナルAへhelloが表示されれば、ブローカーは正常に動作しています。
ステップ3: ESP32からMQTT publish
ここからは、ステップ1で作った温湿度読み取りコードに、Wi-Fi接続とMQTT publishを足していきます。
Arduino IDEのライブラリマネージャで 「PubSubClient」(作者: Nick O'Leary)をインストールします。これはESP32やArduino向けのMQTTクライアントライブラリで、mqtt.connect()やmqtt.publish()といった関数でMQTT通信を簡単に書けるようになります。
#include <Wire.h>
#include <SensirionI2cSht4x.h>
#include <WiFi.h>
#include <PubSubClient.h>
// ===== ここを自分の環境に書き換える =====
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASSWORD = "your-password";
const char* MQTT_SERVER = "<Mosquittoが動くサーバーのLAN IP>";
// ========================================
const int MQTT_PORT = 1883;
const char* MQTT_CLIENT_ID = "esp32-room1";
const char* TOPIC_TEMP = "sensor/room1/temperature";
const char* TOPIC_HUM = "sensor/room1/humidity";
SensirionI2cSht4x sensor;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
void connectWiFi() {
Serial.print("Connecting to WiFi");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("WiFi connected. IP: ");
Serial.println(WiFi.localIP());
}
void connectMQTT() {
while (!mqtt.connected()) {
Serial.print("Connecting to MQTT...");
if (mqtt.connect(MQTT_CLIENT_ID)) {
Serial.println("connected");
} else {
Serial.print("failed, rc=");
Serial.print(mqtt.state());
Serial.println(" retrying in 2s");
delay(2000);
}
}
}
void setup() {
Serial.begin(115200);
delay(1000);
Wire.begin(26, 25); // SDA=GPIO26, SCL=GPIO25 使用するピンに合わせて設定
sensor.begin(Wire, SHT40_I2C_ADDR_44);
connectWiFi();
mqtt.setServer(MQTT_SERVER, MQTT_PORT);
}
void loop() {
if (!mqtt.connected()) {
connectMQTT();
}
mqtt.loop();
float temperature = 0.0;
float humidity = 0.0;
int16_t error = sensor.measureHighPrecision(temperature, humidity);
if (error != 0) {
Serial.println("Error: measurement failed");
} else {
char tempStr[16];
char humStr[16];
// ESP32 Arduino Coreではsnprintfで%fが使えるので、バッファ長を明示できるsnprintfを使う
snprintf(tempStr, sizeof(tempStr), "%.2f", temperature);
snprintf(humStr, sizeof(humStr), "%.2f", humidity);
// QoS 0・retainなしで送信(監視用途では次回値がすぐ来るためこれで許容)
mqtt.publish(TOPIC_TEMP, tempStr);
mqtt.publish(TOPIC_HUM, humStr);
Serial.print("Published -> Temp: ");
Serial.print(tempStr);
Serial.print(" C, Hum: ");
Serial.print(humStr);
Serial.println(" %");
}
delay(10000); // 10秒ごとに送信
}
MQTT_SERVERには、コンテナ名やlocalhostではなく、ESP32から到達可能なMosquittoホストのIPアドレス(通常はDockerホストのLAN IP)を指定します。ESP32はDockerの外の別マシンなので、Docker内部のコンテナ名の名前解決は使えません。
QoSとretainの話(少し細かい)
MQTTには「QoS(Quality of Service)」というメッセージ配信の品質レベルがあります。今回使ったPubSubClientのpublish()はQoS 0(届かなかったら諦める)で送信されます。また、Python側のclient.subscribe(topic)もQoSを指定しなければQoS 0です。
温湿度のように次回値が10秒後にすぐ届く監視ではこれで十分ですが、欠損をできるだけ減らしたい場合はQoS 1(最低1回配送)に上げる方法もあります。ただしPubSubClientではQoS 1のpublishが使えないため、その場合は別のMQTTクライアントライブラリへの変更が必要です。またQoS 1は再送により同じ測定値が複数回Zabbixへ記録される可能性がある点も知っておくとよいです。
また、今回はretain(メッセージ保持)フラグなしで送っているため、後述のmqtt-zabbixコンテナが再起動した直後は、次のESP32送信タイミングまで値を受け取れません。最新値の即時復元が必要な場合は、publish時にretainフラグを立てる(mqtt.publish(TOPIC_TEMP, tempStr, true))ことで対応できます。
動作確認
書き込んで正常に動作すると、シリアルモニタには以下のように表示されます。
Connecting to WiFi....
WiFi connected. IP: 192.168.x.x
Connecting to MQTT...connected
Published -> Temp: 24.15 C, Hum: 52.63 %
connectedとPublishedが出れば、ESP32からMosquittoへのMQTT送信が成功しています。
ステップ4: Zabbix側にホストとトラッパーアイテムを作成
次はZabbix側にデータの受け皿を作ります。
Zabbixトラッパーとは
Zabbixには色々なデータ収集方法がありますが、今回使う「Zabbixトラッパー」は、Zabbixサーバーが受け身でデータを待つタイプのアイテムです。エージェントに問い合わせに行くのではなく、外部からzabbix_senderプロトコルでデータを投げ込んでもらう形になります。今回のように「Pythonスクリプトが集めた値をZabbixに登録する」用途にぴったりです。
ホストとアイテムの作成
Zabbix Web UIから「データ収集 → ホスト → ホストの作成」で、以下の設定を行います。
- ホスト名:
esp32-room1 - テンプレート: なし(手動でアイテムを作るため)
- ホストグループ: 任意(例:
IoT Sensors) - インターフェース: なし(トラッパーはサーバー側が受け取るだけなので不要。ただし「設定してはいけない」わけではなく、インターフェースを残していてもトラッパー自体は動作します)
続けて、作成したホストにアイテムを2つ追加します。
| 項目 | 温度アイテム | 湿度アイテム |
|---|---|---|
| 名前 | Room1 Temperature | Room1 Humidity |
| タイプ | Zabbixトラッパー | Zabbixトラッパー |
| キー | room1.temperature |
room1.humidity |
| データ型 | 数値(浮動小数) | 数値(浮動小数) |
ここで決めたホスト名とアイテムキーは、この後Pythonから送信する際に完全に一致させる必要があります。半角スペースや大文字小文字も含めて一致させないと、Zabbix側で「そんなアイテムはない」と弾かれてデータが入りません。作成した直後は「データなし」と表示されますが、Pythonからデータが送られてくるまではそれで正常です。
ステップ5: MQTT→Zabbixのブリッジ(Python)
最後の仕上げです。Mosquittoに届いたMQTTメッセージを購読して、Zabbixトラッパーに転送するPythonプログラムを作ります。以前のSwitchBot連携で使ったPython + py-zabbixの仕組みとほぼ同じ構造で、データの入手元がAPIからMQTT購読に変わっただけです。
こちらは常時MQTTを購読し続ける常駐プロセスなので、cronではなくDockerコンテナとして常時起動する形にします。
mkdir -p ~/docker/mqtt-zabbix
cd ~/docker/mqtt-zabbix
mqtt_to_zabbix.py
import paho.mqtt.client as mqtt
from pyzabbix import ZabbixMetric, ZabbixSender
import os
# ===== 設定 =====
MQTT_BROKER = os.environ.get("MQTT_BROKER", "mosquitto")
MQTT_PORT = int(os.environ.get("MQTT_PORT", 1883))
ZABBIX_SERVER = os.environ.get("ZABBIX_SERVER", "zabbix-server")
ZABBIX_PORT = int(os.environ.get("ZABBIX_PORT", 10051))
# MQTTトピック → (Zabbixホスト名, アイテムキー) の対応表
TOPIC_MAP = {
"sensor/room1/temperature": ("esp32-room1", "room1.temperature"),
"sensor/room1/humidity": ("esp32-room1", "room1.humidity"),
}
# ================
def on_connect(client, userdata, connect_flags, reason_code, properties):
print(f"Connected to MQTT broker (reason_code={reason_code})")
for topic in TOPIC_MAP:
client.subscribe(topic)
print(f"Subscribed: {topic}")
def on_message(client, userdata, msg):
topic = msg.topic
payload = msg.payload.decode()
if topic not in TOPIC_MAP:
return
host, key = TOPIC_MAP[topic]
try:
value = float(payload)
except ValueError:
print(f"Invalid value on {topic}: {payload}")
return
metric = [ZabbixMetric(host, key, value)]
result = ZabbixSender(zabbix_server=ZABBIX_SERVER, zabbix_port=ZABBIX_PORT).send(metric)
print(f"Sent to Zabbix: {host} {key}={value} (failed={result.failed}, processed={result.processed})")
# paho-mqtt v2のコールバックAPIを明示的に指定(キーワード引数で書くのが安全)
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
protocol=mqtt.MQTTv311,
)
client.on_connect = on_connect
client.on_message = on_message
# 常駐サービスとして堅牢化:接続失敗時に一定間隔で再接続を試みる
client.reconnect_delay_set(min_delay=1, max_delay=60)
client.connect_async(MQTT_BROKER, MQTT_PORT, 60)
client.loop_forever()
コードの気持ちを補足すると、on_connectは「ブローカーに繋がった瞬間に呼ばれる関数」で、ここで購読するトピックを登録します。on_messageは「購読中のトピックにメッセージが届いた瞬間に呼ばれる関数」で、ここで受け取った値をZabbixに転送しています。イベント駆動の書き方に慣れていないと最初はちょっと戸惑いますが、「繋がったらこれをやる」「メッセージが来たらこれをやる」と読めば分かりやすいです。
paho-mqttは1.x系のコールバックAPI(v1)が非推奨になっており、mqtt.Client() に CallbackAPIVersion.VERSION2 を明示的に渡さないとDeprecationWarningが出ます。v2ではコールバックの引数も変わっている(on_connectが connect_flags, reason_code, properties を受け取る)ため、上記コードのように合わせます。
また、connect() の代わりに connect_async() + reconnect_delay_set() を使うことで、起動時にMosquittoへ接続できない場合でも、paho-mqttのネットワークループが再接続を試みる構成にしています。Docker Composeの起動順に依存しにくく、常駐サービス向きの書き方です。
TOPIC_MAPにトピックとZabbixホスト・キーの対応を書いているだけなので、部屋を増やすときはこの表に行を足すだけで拡張できます。
requirements.txt
paho-mqtt
py-zabbix
補足として、PyPI上のパッケージ名は py-zabbix(ハイフン付き)ですが、Pythonコードでのimport名は pyzabbix(ハイフンなし)です。別物に見えますが、正しい組み合わせです。自分は最初これに気付かずに「py-zabbixってimportしたのに認識されない…」と少し悩みました。
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY mqtt_to_zabbix.py .
CMD ["python", "-u", "mqtt_to_zabbix.py"]
-uオプションは出力をバッファリングせず即座にログへ出すためのものです(docker compose logsでリアルタイムに確認できるようにするため)。
docker-compose.yml
このコンテナはshared_networkに載せます。同じネットワーク内では、コンテナ名(mosquitto や zabbix-server)で相手のIPを解決できるので、環境変数にコンテナ名を書くだけで済みます。ステップ3でESP32側はDockerホストのLAN IPを書いたのと違い、コンテナ間の通信ではコンテナ名の名前解決が使えます。Dockerネットワーク内かLAN経由かで名前解決の使い分けが必要というのは、今回一つ勉強になったポイントです。
services:
mqtt-zabbix:
build: .
container_name: mqtt-zabbix
restart: unless-stopped
environment:
- MQTT_BROKER=mosquitto
- ZABBIX_SERVER=zabbix-server
networks:
- shared_network
networks:
shared_network:
external: true
起動して、ログに以下のような出力が出れば成功です。
mqtt-zabbix | Subscribed: sensor/room1/temperature
mqtt-zabbix | Subscribed: sensor/room1/humidity
mqtt-zabbix | Sent to Zabbix: esp32-room1 room1.temperature=27.15 (failed=0, processed=1)
mqtt-zabbix | Sent to Zabbix: esp32-room1 room1.humidity=71.64 (failed=0, processed=1)
failed=0, processed=1が出ていれば、ESP32からZabbixまでのデータパイプラインが完全に通っています。
最終確認
Zabbix Web UIの「監視データ → 最新データ」で、ホストesp32-room1をフィルタすると、Room1 TemperatureとRoom1 Humidityに値が入り、10秒ごとに更新されているのが確認できます。グラフアイコンから時系列グラフも見られます。
ここまで確認できれば、1部屋分の温湿度監視システムが完全に動作している状態です。自分で配線したセンサーの値が、Zabbixのグラフに刻々と描かれていくのを見るのは、初心者的にはかなり感動的でした。
まとめ
電子工作初心者としてESP32とSHT41で温湿度センサーを自作し、MQTT経由でZabbixに取り込むところまでできました。SwitchBotのようなクラウドAPI連携と比べると、ハードウェアの配線・I2C通信・Wi-Fi接続・MQTT通信と工程は増えますが、その分「センサーが値を返さない」原因がハードなのかソフトなのかネットワークなのか、切り分けながら進める経験ができたのが大きな収穫でした。
特に、
- ブレッドボード幅の問題(ESP32-DevKitC-32Eは片側運用でしのいだ)
- Docker内部の名前解決とホストLAN IPの使い分け(ESP32からはLAN IP、コンテナ間はコンテナ名)
このあたりは、同じように初心者からIoT+監視を組んでみたい方の参考になればと思います。
複数部屋への拡張は、ESP32+SHT41をもう1セット作り、コード内のクライアントID・トピック名を変更し、PythonのTOPIC_MAPとZabbixのホスト・アイテムを部屋数分追加するだけで対応できます。部屋数が増える場合は、Zabbixのテンプレート機能を使ってアイテム作成を効率化するのもよさそうです。
最後まで読んでいただきありがとうございました。
参考
- Sensirion I2C SHT4x (Arduino library)
- PubSubClient (Arduino library, 作者: Nick O'Leary)
- Eclipse Mosquitto公式ドキュメント
- Zabbix公式ドキュメント(Trapperアイテム): https://www.zabbix.com/documentation/7.0/jp/manual/config/items/itemtypes/trapper