農業ハウスの温度をESP32で24時間自動監視する超省電力IoTセンサーの作り方

目次


この記事では、市販品に頼らず、自分で作れる温度見守り装置の作り方を、
農家目線・初心者向けに分かりやすく解説しています。
ビニールハウスや育苗室、保管庫などの温度を自動で測定し、
異常があればスマホに通知してくれる仕組みです。

想定は、
育苗期間等の短期間の測定
安価で部品点数少なくコスパ良く

特徴は、
18650リチウムイオン電池1本だけで動作
特別な電源工事は不要で、
電池を入れるだけですぐ使える
温度は自動でクラウド保存
設定温度を超えたらスマホにお知らせ
WiFi設定や温度変更はスマホ画面から簡単操作
といった、「現場で本当に使える」実用重視の構成になっています。

電子工作が初めての方でも取り組めるように、
配線図・プログラム・設定画面・実際の動作例まで順番に説明していますので、
「ハウスの温度を遠隔で見たい」
「夜中に見回りに行きたくない」
「市販品は高いし融通がきかない」

そんな方に特におすすめの内容です。

発展形として、
XIAO ESP32C3はバッテリー充電チップ内蔵のため
リチウムイオン電池の充放電をサポート
USB-C端子にソーラーパネルを接続すれば、
長期の運用が可能

220kΩ抵抗を2本追加する事で電圧を監視する事が出来、
電池切れを予想出来るようになります。

システム概要

(DS18B20 + Discord通知 + Ambient + Web設定)






システムの特徴

  • DS18B20温度センサーによる温度測定
  • 測定時のみセンサーへ給電(省電力設計)
  • 設定温度超過時にDiscordへ自動通知
  • Ambientへ温度データを自動送信
  • DeepSleepによる長時間バッテリー駆動
  • スマホから設定できるWeb設定画面
  • 個体識別用MACアドレス管理

地温・気温測定が出来る温度センサーDS18B20

こちらで使用している温度センサーの特徴として

💧 防水・防湿(屋外向き)

  • センサー本体がステンレス管に封入
  • ケーブル出口も樹脂でモールドされているタイプが多い
  • 水・湿気・結露OK
    → 育苗ハウス、貯蔵庫、屋外設置に強い

🧲 物理的に強い

  • ステンレスプローブなので
  • 土に挿せる
  • 配管に密着させられる
  • 氷水・水中でも使える
  • 樹脂センサーより断然タフ

🆔 個体識別ができる

  • 各センサーに64bit固有ID
  • 1個のESP32C3に複数本接続可能
  • 基板にはセンサー2本接続まで接続可能なので、
  • プログラムコードを工夫すれば、地温気温の同時送信可能
  👉 例
  • ハウス内複数点
  • 地温+気温をまとめて取得

🌡 高精度・デジタル出力

  • 測定範囲:-55〜+125℃
  • 精度:±0.5℃(-10〜+85℃)
  • デジタル通信(1-Wire)なのでノイズに強い
  • → ハウス内、モーター周り、長配線でも安定

まとめると

用  途相性
育苗ハウス温度
地温測定
水温(タンク・用水)
貯蔵庫
精密実験(0.1℃単位)

ひとことで言うと

  • 「農家が自作IoT温度センサーを作るなら、まずこれ」
  • 設置場所を選ばない使いやすさでイチオシのセンサーです😃

使用機材


回路構成

回路図

  • R1:DS18B20の4.7kΩプルアップ抵抗
  • R4:LED用抵抗220Ω
  • J1,J2:DS18B20端子
  • SW1:設定ボタン
  • D1:設定モードLED
  • J1,J2:DS18B20温度センサー端子
  • J3:18650電池端子
  • R2,R3:電圧測定用抵抗(今回は使用しません)




プログラムスケッチ

開発環境 Arduino IDE 2.3.7
追加インストール 
ESP32 by Espressif Systems(ボードマネージャ)

ライブラリ
OneWire
DallasTemperature
Ambient
Discord_WebHook    
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <Discord_WebHook.h>
#include <Ambient.h>
#include <esp_sleep.h>
#include <esp_system.h>

// ===== Discord Webhook URL を (channel_id, token) に分解(チェック無し)=====
// URL形式: https://discord.com/api/webhooks/<id>/<token>
// 戻り値: 分解できたら true、できなければ false
bool splitDiscordWebhook(const String& url, String& outId, String& outToken) {
  outId = "";
  outToken = "";

  int p = url.indexOf("/webhooks/");
  if (p < 0) return false;

  int start = p + String("/webhooks/").length();
  int slash = url.indexOf('/', start);
  if (slash < 0) return false;

  outId = url.substring(start, slash);
  outToken = url.substring(slash + 1);

  outId.trim();
  outToken.trim();

  return (outId.length() > 0 && outToken.length() > 0);
}

#define CONFIG_BUTTON_PIN 10
#define LED_PIN 5

#define SENSOR_POWER_PIN 20
#define ONE_WIRE_PIN 4

Preferences preferences;
WebServer server(80);
WiFiClient client;
Discord_Webhook discord;
Ambient ambient;

OneWire oneWire(ONE_WIRE_PIN);
DallasTemperature sensors(&oneWire);

// ===== 設定値 =====
String kotaiID = "5";
float thresholdTemp = 28.0;
unsigned long sleepSec = 300;
String WIFI_SSID = "MySSID";
String WIFI_PASSWORD = "MyPASS";
String ambientUserKey = "MyUserKey";
String DISCORD_WEBHOOK = "https://discord.com/api/webhooks/1234567890/AbcdefghijklmnopQrstuvwxyZ";
bool ENABLE_DISCORD = true;

// 表示&devKey用(コロン無しMAC)
String MAC_ADDR;

void loadSettings() {
  preferences.begin("config", true);
  kotaiID         = preferences.getString("kotaiID", kotaiID);
  thresholdTemp   = preferences.getFloat("threshold", thresholdTemp);
  sleepSec        = preferences.getULong("sleep", sleepSec);
  WIFI_SSID       = preferences.getString("ssid", WIFI_SSID);
  WIFI_PASSWORD   = preferences.getString("pass", WIFI_PASSWORD);
  ambientUserKey  = preferences.getString("ambient", ambientUserKey);
  DISCORD_WEBHOOK = preferences.getString("discord", DISCORD_WEBHOOK);
  ENABLE_DISCORD  = preferences.getBool("enable_discord", ENABLE_DISCORD);
  preferences.end();
}

void saveSettings() {
  preferences.begin("config", false);
  preferences.putString("kotaiID", kotaiID);
  preferences.putFloat("threshold", thresholdTemp);
  preferences.putULong("sleep", sleepSec);
  preferences.putString("ssid", WIFI_SSID);
  preferences.putString("pass", WIFI_PASSWORD);
  preferences.putString("ambient", ambientUserKey);
  preferences.putString("discord", DISCORD_WEBHOOK);
  preferences.putBool("enable_discord", ENABLE_DISCORD);
  preferences.end();
}

float measureTempC() {
  digitalWrite(SENSOR_POWER_PIN, HIGH);
  delay(80);

  sensors.begin();
  sensors.requestTemperatures();
  delay(750);

  float t = sensors.getTempCByIndex(0);

  digitalWrite(SENSOR_POWER_PIN, LOW);
  return t;
}

bool connectWiFi(uint32_t timeoutMs = 10000) {
  // まれな不安定対策:一度OFF→STA
  WiFi.mode(WIFI_OFF);
  delay(50);
  WiFi.mode(WIFI_STA);

  WiFi.begin(WIFI_SSID.c_str(), WIFI_PASSWORD.c_str());

  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeoutMs) {
    delay(100);
  }
  return (WiFi.status() == WL_CONNECTED);
}

void wifiOff() {
  WiFi.disconnect(false);
  WiFi.mode(WIFI_OFF);
}

void goSleep() {
  esp_sleep_enable_timer_wakeup(sleepSec * 1000000ULL);
  esp_deep_sleep_start();
}

// ===== 設定画面(MACもコロン無し表示) =====
void handleRoot() {
  String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
                "<meta name='viewport' content='width=device-width, initial-scale=1'>"
                "<style>body{font-family:sans-serif;padding:20px;background:#f9f9f9}"
                "form{background:#fff;padding:20px;border-radius:10px;max-width:600px}"
                ".row{display:flex;align-items:center;margin-bottom:12px}"
                ".row label{width:180px;font-weight:bold}"
                ".row input{width:240px;padding:6px}"
                ".row input[type=checkbox]{width:auto;margin-left:0;transform:scale(1.4)}"
                ".disabled{background:#eee;color:#999}"
                "</style>"
                "<script>"
                "function toggleDiscord(){"
                "  var cb = document.querySelector(\"input[name='enable_discord']\");"
                "  var w = document.getElementById('discord');"
                "  if(cb && w){"
                "    if(cb.checked){"
                "      w.disabled = false;"
                "      w.classList.remove('disabled');"
                "    }else{"
                "      w.disabled = true;"
                "      w.classList.add('disabled');"
                "    }"
                "  }"
                "}"
                "</script>"
                "</head><body onload='toggleDiscord()'>";

  html += "<h2>ESP32温度センサー設定</h2><form method='POST' action='/save'>";
  html += "<div class='row'><label>MAC</label><div>" + MAC_ADDR + "</div></div>";
  html += "<div class='row'><label>個体ID</label><input name='id' value='" + kotaiID + "'></div>";
  html += "<div class='row'><label>通知閾値(℃)</label><input name='threshold' type='number' step='0.1' value='" + String(thresholdTemp) + "'></div>";
  html += "<div class='row'><label>測定間隔(秒)</label><input name='sleep' type='number' value='" + String(sleepSec) + "'></div>";
  html += "<div class='row'><label>WiFi SSID</label><input name='ssid' value='" + WIFI_SSID + "'></div>";
  html += "<div class='row'><label>WiFi PASS</label><input name='pass' type='password' value='" + WIFI_PASSWORD + "'></div>";
  html += "<div class='row'><label>Ambient UserKey</label><input name='ambient' value='" + ambientUserKey + "'></div>";

  // Discord有効時は編集可、無効時はグレー&編集不可(初期状態)
  html += "<div class='row'><label>Discord Webhook</label>"
          "<input id='discord' name='discord' value='" + DISCORD_WEBHOOK + "' "
          + String(ENABLE_DISCORD ? "" : "disabled class='disabled'") +
          "></div>";

  html += "<div class='row'><label>Discord有効</label>"
          "<input type='checkbox' name='enable_discord' value='1' onchange='toggleDiscord()'";
  if (ENABLE_DISCORD) html += " checked";
  html += "></div>";

  html += "<input type='submit' value='保存'></form></body></html>";

  server.send(200, "text/html", html);
}

void handleSave() {
  kotaiID        = server.arg("id");
  thresholdTemp  = server.arg("threshold").toFloat();
  sleepSec       = server.arg("sleep").toInt();
  WIFI_SSID      = server.arg("ssid");
  WIFI_PASSWORD  = server.arg("pass");
  ambientUserKey = server.arg("ambient");

  // disabled のときはブラウザが送らない → hasArgで保持
  if (server.hasArg("discord")) {
    DISCORD_WEBHOOK = server.arg("discord");
  }

  ENABLE_DISCORD = server.hasArg("enable_discord");

  saveSettings();

  server.send(200, "text/html; charset=UTF-8",
              "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
              "<meta name='viewport' content='width=device-width, initial-scale=1'>"
              "<style>"
              "body{font-family:sans-serif;text-align:center;padding:40px;}"
              ".msg{font-size:32px;font-weight:bold;}"
              "</style>"
              "</head><body>"
              "<div class='msg'>保存しました。<br>再起動します...</div>"
              "</body></html>");

  delay(500);
  ESP.restart();
}

void startConfigPortal() {
  digitalWrite(LED_PIN, HIGH);

  // 複数台でも混乱しないAP名(MAC末尾)
  String apSsid = "ESP32_Config_" + MAC_ADDR.substring(8); // 例: AABBCCDD
  WiFi.mode(WIFI_AP);
  WiFi.softAP(apSsid.c_str());

  server.on("/", handleRoot);
  server.on("/save", HTTP_POST, handleSave);
  server.begin();

  Serial.print("設定モード開始 AP SSID: ");
  Serial.println(apSsid);
  Serial.print("IP: ");
  Serial.println(WiFi.softAPIP());

  while (true) {
    server.handleClient();
    delay(10);
  }
}

// ===== メイン =====
void setup() {
  Serial.begin(115200);

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(CONFIG_BUTTON_PIN, INPUT_PULLUP);
  pinMode(SENSOR_POWER_PIN, OUTPUT);
  digitalWrite(SENSOR_POWER_PIN, LOW);

  // ★ MAC(WiFi.macAddress() / コロン無し)
  WiFi.mode(WIFI_STA);
  MAC_ADDR = WiFi.macAddress();   // "AA:BB:CC:DD:EE:FF"
  MAC_ADDR.replace(":", "");      // "AABBCCDDEEFF"

  Serial.print("MAC: ");
  Serial.println(MAC_ADDR);

  loadSettings();

  if (digitalRead(CONFIG_BUTTON_PIN) == LOW) {
    Serial.println("設定ボタン押下 → 設定モードへ");
    startConfigPortal();
  }

  // 温度計測(省電力:センサー電源ONはこの瞬間だけ)
  float temperature = measureTempC();
  Serial.print("計測温度: ");
  Serial.print(temperature);
  Serial.println(" ℃");

  bool sendToDiscord = false;
  String msg;

  if (temperature == -127.0) {
    msg = "⚠️ DS18B20 read failed. Temp: -127.0°C";
    sendToDiscord = true;
  } else if (temperature > thresholdTemp) {
    msg = "🔥 異常温度: " + String(temperature) + "°C (ID:" + kotaiID + ")";
    sendToDiscord = true;
  }

  // Wi-Fi接続(必要なときだけON)
  Serial.print("WiFi接続: ");
  bool wifiOK = connectWiFi(10000);
  Serial.println(wifiOK ? "OK" : "NG");

  // ★ WiFi詳細表示
  if (wifiOK) {
    Serial.print("IP: ");
    Serial.println(WiFi.localIP());
    Serial.print("RSSI: ");
    Serial.println(WiFi.RSSI());
  }

  if (wifiOK) {
    // Discord(異常時のみ)
    if (ENABLE_DISCORD && sendToDiscord && DISCORD_WEBHOOK.length() > 0) {
      Serial.print("Discord送信: ");
      Serial.println(msg);

      String cid, tok;
      if (splitDiscordWebhook(DISCORD_WEBHOOK, cid, tok)) {
        discord.begin(cid, tok);
        discord.send(msg);
      } else {
        Serial.println("Discord送信: Webhook URL を分解できません");
      }
    } else {
      Serial.println("Discord送信: なし");
    }

    // Ambient
    if (ambientUserKey.length() > 0) {
      char devKey[32], writeKey[20];

      strncpy(devKey, MAC_ADDR.c_str(), sizeof(devKey) - 1);
      devKey[sizeof(devKey) - 1] = '\0';

      unsigned int channelId;
      Serial.print("Ambient送信: ");

      if (!ambient.getchannel(ambientUserKey.c_str(), devKey, channelId, writeKey, sizeof(writeKey), &client)) {
        Serial.println("NG(チャンネル情報取得失敗)");
        Serial.print("devKey=");
        Serial.println(devKey);
      } else {
        // writeKey掃除(NULL終端保証 → sizeofベース)
        writeKey[sizeof(writeKey) - 1] = '\0';
        for (int i = 0; i < (int)sizeof(writeKey); i++) {
          if (writeKey[i] == '}' || writeKey[i] == '"') writeKey[i] = '\0';
        }

        ambient.begin(channelId, writeKey, &client);
        ambient.set(1, temperature);

        Serial.print("channelId=");
        Serial.println(channelId);
        Serial.print("writeKey=");
        Serial.println(writeKey);

        Serial.println(ambient.send() ? "OK" : "NG(送信失敗)");
      }
    } else {
      Serial.println("Ambient送信: なし(UserKey未設定)");
    }
  }

  wifiOff();
  digitalWrite(LED_PIN, LOW);

  goSleep();
}

void loop() {}

MACアドレス取得について

Ambient公式サイト 複数台のIoT端末を同一プログラムで動かすhttps://ambidata.io/docs/getchannel/

こちらに書かれているコード、環境(Arduino-ESP32 core の版)によって
esp_read_mac() と ESP_MAC_WIFI_STA が存在しないことがあって、
そこでコケます。
👉 Arduino環境やPCが変わると
👉 「未定義エラー」になることがあります。
実際に、
'esp_read_mac' was not declared in this scope
のようなエラーが出る場合があります。

一番ラクで安定なのは、ESP-IDF依存のAPIを使わず
WiFi.macAddress() で取得する方法です。コロン無しにも簡単にできます。

✅ 基本形(コロン付き)

まずは通常のMAC取得:
WiFi.mode(WIFI_STA);
String mac = WiFi.macAddress();

これで、
AA:BB:CC:DD:EE:FF

という形式でMACアドレスが取得できます。

✅ コロン無しに変換する方法

AmbientのdevKeyや識別用には
AABBCCDDEEFF

のようなコロン無し形式が便利です。
これは replace() を使うだけで簡単にできます。

こちらの部分↓
  WiFi.mode(WIFI_STA);                // 先にSTAにしておくと安定
  MAC_ADDR = WiFi.macAddress();       // 例 "AA:BB:CC:DD:EE:FF"
  MAC_ADDR.replace(":", "");     // 例 "AABBCCDDEEFF"

AmbientでのMAC アドレス登録画面

ESP32C3のMACアドレスは、Ambientでデバイスキーとして使用されて、
それぞれのチャンネルに対応付けされます。

実機構成

防水ケース内部構成

  • ESP32基板固定
  • 18650バッテリー内蔵
  • センサーケーブル引き出し
  • 動作LED確認窓
  • 防水DS18B20温度プローブ
  • ハウス内や倉庫へ設置可能
温度センサーケース入り

動作フロー

起動後、以下の流れで自動動作します。

DeepSleep後は、設定した測定間隔で起動して温度測定を繰り返す

設定画面

設定ボタンを押しながら電源を入れると
ESP32がアクセスポイントとして起動します。

SSID例: ESP32_Config_XXXXXX

スマホから接続し、以下の設定が可能です。
⚫︎MACアドレス(個体識別)
測定データをグラフ表示するために必要な項目

⚫︎個体ID
通知メッセージに付加してどの温度センサーか識別します

⚫︎温度閾値
通知する温度設定

⚫︎測定間隔
測定間隔を秒で指定

⚫︎WiFi設定
SSIDとパスワードを設定

⚫︎Ambient UserKey
Ambientユーザーキー

⚫︎Discord Webhook
温度通知に使用するURL

⚫︎Discord通知ON/OFF
温度通知ON OFFのチェック
チェックを外すと通知しなくなります

保存完了画面

保存後、自動的に再起動します。


Discord異常通知

以下の場合に自動通知されます。

  • 温度が通知閾値を超過した場合
  • センサーエラー発生時
    • -127℃エラー 「-127 °C」 が出る場合は、ほぼ確実に
      👉 センサーと通信できていない(検出失敗) という意味です。
    • 断線、もしくはセンサーの故障が考えられます。

通知例:


Ambientデータ送信

  • MACアドレスをdevKeyとして使用
  • 個体ごとに自動チャンネル作成
  • 温度データを時系列で記録

専用基板

今回の装置では、ブレッドボード配線ではなく**専用の基板(プリント基板)**を作っています。

専用基板にすることで、
配線ミスがなくなる
センサー用電源を測定時だけONにできる
ケースに収まり、現場設置が簡単になる
といったメリットがあります。

※R2、R3は電圧測定用抵抗
 今回は使用しません
KiCadで作成した配線図

基板&配線サポート台座

台座を3DCADにて設計
3Dプリンターで作成
温度センサーやバッテリーケースのリード線は
細くて断線しやすいので、
台座にサポートブロックを設けています

まとめ

このシステムにより

  • 完全自動温度監視
  • 超省電力運用
  • 遠隔通知
  • データ記録
  • Web設定

をすべて実現できます。

農業・設備管理・防災用途など幅広く応用可能なIoT温度監視システムです。

参考動画

ESP32C3のリチウムイオンバッテリー端子へのハンダ付け解説

参考URL

Ambient IoTデータ可視化サービス
https://ambidata.io

Seeed Studio XIAO ESP32C3 を使い始める
https://wiki.seeedstudio.com/XIAO_ESP32C3_Getting_Started/

KiCad クロスプラットフォームでオープンソースのPCB設計スイート
https://www.kicad.org/

JLCPCB プリント基板発注
https://jlcpcb.com/

Discord 外部サーバー連携
https://discord.com/
  • このエントリーをはてなブックマークに追加
  • follow us in feedly

この記事の著者

momo

1966年訓子府町生まれの訓子府育ち。玉葱や米、メロンを栽培する農家です。一眼レフを本格的に始めたのは2005年。仕事の時でもいつでもカメラを持ち歩く自称農場カメラマン。普段の生活を撮るのが主で、その他ストロボを使っての商品撮影、スタジオ撮影も。愛好家グループで年1回写真展を行っている。農機具の改造や作製、電子工作など、モノづくりが大好きです。

この著者の最新の記事

関連記事

コメント

  1. この記事へのコメントはありません。

  1. この記事へのトラックバックはありません。

2026年2月
 1
2345678
9101112131415
16171819202122
232425262728  

カテゴリー

ページ上部へ戻る