🏡

SwitchBot防水温湿度計とESP32で自宅環境の観測をしてみた

2024/04/28に公開

複数のSwitchBot防水温湿度計とESP32ボードを使い、自宅環境がリアルタイムに把握できる観測環境を構築してみました。

きっかけ

数年ぶりにトウガラシを自宅で栽培してみようと思い、2月下旬に種まきをして順調に成長していました。トウガラシは暖地を好み、20℃以上で生育して15℃未満になると低温障害が発生する危険があります。4月下旬まで降雪・降霜がある地方に居住しているため、苗がしっかりと育つまでは気温に注意して育てる必要があります。

もちろん、温度計を使えば外気温を確認できますが、いちいち温度計の設置場所まで赴いて確認する手間を省きたいと考えました。自宅の居室内から屋外の気温がわかればよいので、今回はネット上に情報は流さず、自宅ネットワーク内の範囲で温湿度が把握できるような最低限のシステムを構築しました。

構成

長期間電池駆動でき、Bluetooth LE(BLE)で温湿度を取得できる SwitchBot 防水温湿度計 を使ってベランダと窓辺の温湿度を計測することにしました。

なお、この温湿度計はSwitchBotのアプリを使えばインターネットを介して温湿度を確認できます。しかしアプリのアカウントが必要なこと、任意のセンサーを使いたいこと、今のところ自宅内からのみの確認でよいことなどから、SwitchBotアプリを使わない方法をとりました。

その任意のセンサーはBME280(温湿度・気圧計)とMH-Z19C(CO2)を使います。BME280は防水温湿度計よりも精度が高く、気圧の測定もできます。気圧とCO2センサは屋外に設置する意味は薄いので、ESP32ボードに直接接続することにしました。

Webサーバは最終的にブラウザに表示する際のアセットを提供するために使います。ESP32は観測データを格納したJSONだけを提供し、閲覧時にそれを取得してブラウザ上に表示させます。

屋外のどこに設置する?

SwitchBot防水温湿度計は防水を謳っていますが風雨が直接当たると劣化の進行や砂塵(特に黄砂)で汚れるおそれがあり心配です。さらにセンサー部分が直射日光に当たるのも計測方法としては適切ではないので、直射日光が当たらず、通気性がよく、1.5m~2.0m程度の高さにセンサーを置くことにしました。

実は昨年2023年に別のセンサーを設置するために百葉箱(のようなもの)を制作してベランダに設置しました。別の構成のセンサーや基板が入っていましたが放置状態だったため、これを期に古い基板は全て撤去してSwitchBot防水温湿度計を入れることにしました。

この百葉箱は下記の記事を参考にして、プラスチック製の植木鉢と受け皿を組み合わせて作ったものです。1年間の放置で砂塵が外装部分に溜まっていましたが、内部の基板には汚れがなく、配線類も錆などがなかったことを確認しています。

事前調査

これまでBLEを扱ったことはなかったので、アドバタイズとスキャンについて事前に調べておきました。SwitchBotはアクティブスキャンを行うことでアドバタイズパケットが送信されて各種情報が得られることも事前に調査しておきました。ESP32でSwitchBot防水温湿度計のデータを直接取得する方法については別の記事にて公開しています。

ESP32で防水温湿度計からのデータを受信できることを確認したら、2台の防水温湿度計のBLEアドレス(MACアドレス)を記録しておきます。

配線

ESP32に直接接続するBME280とMH-Z19Cの配線と動作確認だけ事前に済ませておきます。

BME280はI2C接続で行います。今回は Wire1 を使って接続します。GPIO33がSDA、GPIO32がSCLです。

ESP32でBME280から観測データを取得するプログラム(例)
#include <Arduino.h>
#include <Wire.h>
#include "Adafruit_BME280.h"

Adafruit_BME280 bme;

void setup() {
  Wire1.begin(33, 32);
  if (!bme.begin(BME280_ADDRESS, &Wire1)) {
    Serial.println(F("Could not find a valid BME280 sensor, check wiring!"));
    while (1)
      delay(1);
  }
  bme.setSampling(Adafruit_BME280::MODE_NORMAL, Adafruit_BME280::SAMPLING_X16, Adafruit_BME280::SAMPLING_X16,
                  Adafruit_BME280::SAMPLING_X16, Adafruit_BME280::FILTER_X8, Adafruit_BME280::STANDBY_MS_20);
}

void loop() {
  printf("Tmp: %.1f C, Hum: %.1f %%, Prs: %.1f hPa\n", bme.readTemperature(), bme.readHumidity(),
         bme.readPressure() / 100.0);

  delay(1000);
}

MH-Z19CはPWM接続で値を読み取ります。PWM周波数はおよそ1Hz、Hレベルの時間がCO2濃度に対応します。データシートではCO2濃度 C_\text{ppm} の算出に、

C_\text{ppm} = 2000 \cdot \frac{T_\text{H} - 2\text{[ms]}}{T_\text{H} + T_\text{L} - 4\text{[ms]}}

との式が記載されています。(ここで T_\text{H} はHレベル時間 [ms]、T_\text{L} はLレベル時間 [ms])
ところが上記の計算式は誤りで、実際に計測すると200ppm未満の低すぎる値が出ます。正しい計算式は

C_\text{ppm} = 5000 \cdot \frac{T_\text{H} - 2\text{[ms]}}{T_\text{H} + T_\text{L} - 4\text{[ms]}}

になります。
MH-Z19Cは5Vの電源が必要なので、ESP32ボードの5VピンとMH-Z19CのVinピンと直接接続します。PWM出力ピンはGPIO12に接続します。

ESP32でMH-Z19CからCO2濃度を取得するプログラム(例)
#include <Arduino.h>

template <uint8_t pin>
class MH_Z19C_PWM {
 public:
  void begin() {
    pinMode(pin, INPUT);
    attachInterrupt(pin, onPWMEdgeCanged, CHANGE);
  }

  uint16_t getPPM() {
    return MH_Z19C_PWM::ppm;
  }

 private:
  static volatile int64_t lastRiseTime;
  static volatile int64_t lastFallTime;
  static volatile uint16_t ppm;

  static void onPWMEdgeCanged();
};

template <uint8_t pin>
volatile int64_t MH_Z19C_PWM<pin>::lastRiseTime = 0;
template <uint8_t pin>
volatile int64_t MH_Z19C_PWM<pin>::lastFallTime = 0;
template <uint8_t pin>
volatile uint16_t MH_Z19C_PWM<pin>::ppm = 0;

template <uint8_t pin>
void MH_Z19C_PWM<pin>::onPWMEdgeCanged() {
  int64_t now = micros();

  if (digitalRead(pin)) {
    int64_t t    = now - lastRiseTime;
    int64_t th   = lastFallTime - lastRiseTime;
    ppm          = (uint16_t)(5000.0 * (th / 1000.0 - 2.0) / (t / 1000.0 - 4.0));
    lastRiseTime = now;
  } else {
    lastFallTime = now;
  }
}

MH_Z19C_PWM<12> co2sensor;

void setup() {
  co2sensor.begin();
}

void loop() {
  printf("CO2: %d ppm\n", co2sensor.getPPM());
  delay(1000);
}

コーディング

ESP32から取得するデータスキーマ

ESP32からWiFi経由で取得するJSONのスキーマをまず決めます。Vueを使って閲覧画面をつくるため、今回はTypeScriptでJSONのスキーマを定義しました。

各観測機(防水温湿度計、ESP32)を Observator とし、それらが各々別々のセンサー値 SensorValue を持ちます。防水温湿度計、ESP32は「気温」「湿度」という同じセンサータイプを持っていますが、精度が異なります。現在は観測機が3台2種だけですが、将来増えることも考え、精度や名前、単位の情報も持つようにしました。

30秒に1回の頻度でBLEのアドバタイズパケットを取得しますが、必ずパケットが返ってくるわけではなく、さらに複数個同じパケットが返ってくることもあります。何回目のパケットの情報を取得したかを表す sequence、いつパケットを取得したかを表す fetchedAt の情報も入れました。

src/type/observator.ts
export type SensorType = 'temperature' | 'humidity' | 'pressure' | 'co2' | 'battery' | 'rssi';
export type ObservatorType = 'W3200010' | 'ESP32-Central';

export interface SensorValue<T extends SensorType> {
  type: T;
  name?: string;
  value: number;
  unit: string;
  precision: number;
}

export interface Observator {
  address: string;
  type: ObservatorType;
  sequence: number;
  fetchedAt: number;
  sensor: SensorValue<SensorType>[];
}

export interface ObservatorW3200010 extends Observator {
  type: 'W3200010';
  sensor: [SensorValue<'temperature'>, SensorValue<'humidity'>, SensorValue<'battery'>, SensorValue<'rssi'>];
}

export interface ObservatorESP32Central extends Observator {
  type: 'ESP32-Central';
  sensor: [SensorValue<'temperature'>, SensorValue<'humidity'>, SensorValue<'pressure'>, SensorValue<'co2'>];
}

sensor は配列としました。将来、1つの観測機が同じセンサータイプの数値を複数返すことも考えられるので、あらかじめ順序を決めて区別できるようにしておきます。

あとはESP32がこの Observator を配列で返せばJSONのスキーマとなります。ここにも通し番号として sequence は付加しておきます。

src/type/observator.ts
// JSONで取得できるコンテナ
export interface ObservationResultContainer {
  sequence: number;
  result: Observator[];
}

// 観測機に固有の名前をつけるための型定義
export interface ObservatorItem {
  address: string;
  name: string;
  result?: Observator;
}

ESP32からJSONを返す

JSONのスキーマが決まったので、ESP32側からこれを返すプログラムを書きます。JSONのライブラリを使ってもよいのですが、今回は簡単に、String に直接追記して返すだけにしました。

自宅ネットワークとはいえ別IPアドレス上のWebページからJSONを取得しようとするとCORSに引っかかるので server.enableCORS(true) を忘れずに指定して有効にしておきます。

なお実際のコードでは fetchedAt で時刻を返す必要があるため、NTPによる時刻合わせも行っています。

ESP32からJSONを返すプログラム(一部)
httpServer.cpp
WebServer server(80);

void handleObservationResult() {
  String json = "{";
  json += "\"sequence\":" + String(observationSequenceNumber) + ",";
  json += "\"result\":[";

  bool continued = false;
  for (auto x = observationResults.begin(); x != observationResults.end(); x++) {
    BLEAddress address       = x->first;
    ObservationResult result = x->second;

    if (continued)
      json += ",";

    json += "{";
    json += "\"address\":\"" + String(address.toString().c_str()) + "\",";
    json += "\"type\":\"W3200010\",";
    json += "\"sequence\":" + String(result.sequenceNumber) + ",";
    json += "\"fetchedAt\":" + String(result.fetched_at) + ",";
    json += "\"sensor\":[";
    {
      json += "{\"type\":\"temperature\",";
      json += "\"value\":" + String(result.temperature, 1) + ",";
      json += "\"unit\":\"℃\",";
      json += "\"precision\":1},";

      json += "{\"type\":\"humidity\",";
      json += "\"value\":" + String(result.humidity) + ",";
      json += "\"unit\":\"%\",";
      json += "\"precision\":0},";

      json += "{\"type\":\"battery\",";
      json += "\"value\":" + String(result.battery) + ",";
      json += "\"unit\":\"%\",";
      json += "\"precision\":0},";

      json += "{\"type\":\"rssi\",";
      json += "\"value\":" + String(result.rssi) + ",";
      json += "\"unit\":\"dBm\",";
      json += "\"precision\":0}";
    }
    json += "]";
    json += "}";

    continued = true;
  }

  if (continued)
    json += ",";

  {
    BLEAddress macAddress("00:00:00:00:00:00");
    esp_read_mac((uint8_t *)macAddress.getNative(), ESP_MAC_BT);
    struct tm datetime;
    getLocalTime(&datetime);

    json += "{";
    json += "\"address\":\"" + String(macAddress.toString().c_str()) + "\",";
    json += "\"type\":\"ESP32-Central\",";
    json += "\"sequence\":" + String(observationSequenceNumber) + ",";
    json += "\"fetchedAt\":" + String(mktime(&datetime)) + ",";
    json += "\"sensor\":[";
    {
      json += "{\"type\":\"temperature\",";
      json += "\"value\":" + String(bme.readTemperature(), 2) + ",";
      json += "\"unit\":\"℃\",";
      json += "\"precision\":2},";

      json += "{\"type\":\"humidity\",";
      json += "\"value\":" + String(bme.readHumidity(), 3) + ",";
      json += "\"unit\":\"%\",";
      json += "\"precision\":3},";

      json += "{\"type\":\"pressure\",";
      json += "\"value\":" + String(bme.readPressure() / 100.0, 4) + ",";
      json += "\"unit\":\"hPa\",";
      json += "\"precision\":4},";

      json += "{\"type\":\"co2\",";
      json += "\"value\":" + String(co2sensor.getPPM()) + ",";
      json += "\"unit\":\"ppm\",";
      json += "\"precision\":0}";
    }
    json += "]";
    json += "}";
  }

  json += "]";
  json += "}";

  server.send(200, "application/json", json);
}

void setupHTTPServer() {
  server.on("/observation/result", handleObservationResult);
  server.enableCORS(true);
  server.begin();
  Serial.println("HTTP server started");
}

Vue + Vuetify + TypeScript

HTTPでJSONを受け取れる状態になったので、Vuetifyで閲覧画面を作っていきます。以前に既にVuetifyを使ってJSONを取得して画面に表示させるサイトは構築して運用しているので、このページのコードをベースにして新しいページを構築していきます。

構築時の環境は次のとおりです。

  • Vue: 3.4.25
  • Vuetify: 3.5.17
  • Vite: 5.2.10
  • TypeScript: 5.4.5

JSONを定期的に取得

定期的に何か処理させる場合は setInterval を使うことが多いですが、エラー発生時は処理を分けたい(通常よりも間隔をあけたい)ときは setTimeout のほうが便利です。しかし clearInterval を使ってタイムアウトの解除を行う必要があります。これらの処理はパターン化しているので、以下のような definePeriodicCall 関数を作って済ませています。

import { onMounted, onBeforeUnmount } from 'vue';

export function definePeriodicCall(
  onCalled: () => Promise<number | undefined>,
  onError?: (error: any) => Promise<number | undefined>,
): void {
  let intervalObj: number;
  let waitTime: number | undefined;

  const callee = async function () {
    try {
      waitTime = await onCalled();

      if (typeof waitTime !== 'undefined') {
        intervalObj = setTimeout(callee, waitTime * 1000);
      }
    } catch (ex) {
      if (onError) {
        waitTime = await onError(ex);

        if (typeof waitTime !== 'undefined') {
          intervalObj = setTimeout(callee, waitTime * 1000);
        }
      }
    }
  };

  onMounted(async () => {
    await callee();
  });

  onBeforeUnmount(() => {
    clearTimeout(intervalObj);
  });
}

利用側は次の実行秒数を返すようなコールバック関数を書くだけです。通常時は10秒ごと、エラー時は60秒後に定時処理を行います。

const errorSnackbar = ref<boolean>();  // エラーが起きたことを画面上に表示するフラグ

definePeriodicCall(
  async () => {
    errorSnackbar.value = false;
    const container = (await axios.get<ObservationResultContainer>(sourceUri)).data;
    fetchedAt.value = dayjs();

    /* JSONを使った処理(省略) */

    return 10;
  },
  async (error) => {
    console.error(`Fetching error. Retrying in 1 minute: ${error}`);
    errorSnackbar.value = true;
    return 60;
  },
);

観測対象ごとに出し分け

防水温湿度計とESP32で表示できる項目が異なるので、 Observator.type の値で型ガードできる関数を用意しておきます。

<script setup lang="ts">
const { observator } = defineProps<{
  observator: ObservatorItem;
}>();

function is<T extends Observator>(observator: Observator | undefined, targetType: ObservatorType): observator is T {
  return observator?.type === targetType;
}
</script>

あとは Observator.type に従って表示させるコンポーネントを出し分けます。

<template>
  <v-card variant="elevated" v-bind="props" class="h-100">
    <v-card-text class="h-100">
      <W3200010Card
        v-if="is<ObservatorW3200010>(observator.result, 'W3200010')"
        :observator="observator.result"
        :name="observator.name"
      />
      <ESP32Card
        v-if="is<ObservatorESP32Central>(observator.result, 'ESP32-Central')"
        :observator="observator.result"
        :name="observator.name"
      />
    </v-card-text>
  </v-card>
</template>

観測結果の表示

あとはセンサーの種類ごとに観測結果を表示させるだけです。気圧は現地気圧のままになっているので、わかりやすいように海面更正を行って海面気圧で表示しています。

<script setup lang="ts">
import { JST } from '@/lib/dayjs';
import type { ObservatorESP32Central } from '@/type/observator';

const { observator, name } = defineProps<{
  observator: ObservatorESP32Central;
  name: string;
}>();

function fill(text: string, filler: string): string {
  return text.length === 0 ? filler : text;
}

function calcSeaPressure(): number {
  const z = Number(import.meta.env.VITE_DASHBOARD_SENSOR_ALTITUDE);
  return (
    observator.sensor[2].value * Math.pow(1 - (0.0065 * z) / (observator.sensor[0].value + 0.0065 * z + 273.15), -5.257)
  );
}
</script>
<template>
  <v-row no-gutters class="h-100" justify="start">
    <v-col cols="12" class="align-self-start">
      <div class="text-green-darken-2 queued">
        <span class="text-h5 font-weight-bold">{{ observator.sensor[0].value.toFixed(2) ?? '--' }}</span>
        <span class="font-weight-bold"></span>
      </div>
      <div class="text-blue-darken-2 queued">
        <span class="text-h5 font-weight-bold">{{ observator.sensor[1].value.toFixed(1) ?? '--' }}</span>
        <span class="font-weight-bold">%</span>
      </div>
      <div class="text-red-darken-3 queued">
        <span class="text-h5 font-weight-bold">{{ calcSeaPressure().toFixed(1) ?? '--' }}</span>
        <span class="font-weight-bold">hPa</span>
      </div>
      <div class="text-yellow-darken-3 queued">
        <span class="text-h5 font-weight-bold">{{ observator.sensor[3].value ?? '--' }}</span>
        <span class="font-weight-bold">ppm</span>
      </div>
      <div>{{ fill(name, '(no name)') }}</div>
    </v-col>

<!-- 以下略 -->

できたもの

冒頭と同じ画像ですが、こちらが今回できたものになります。

閲覧ページを表示するとESP32からJSONを取得して観測結果が表示されます。各観測項目をクリックすると観測機の名前をつけることができます。この名前はブラウザの localStorage に保存しています。
右上の現在時刻の隣りにあるのはライトテーマとダークテーマの切り替えボタンです。

各所に表示されている時計の表示は情報の取得からの経過時間を現しています。BLEで取得した観測結果は30秒に1回のBLEスキャンをかけても必ず受信できるわけではありません。そこで表示している観測結果が本当に新しいものなのかがわかる必要があります。右上に現在時刻も表示させているのもその理由です。

閲覧ページは公開状態になっていませんが、ソースは以下にて公開しました。

今後

観測機が複数あっても柔軟に対応できるようにしたので、今後は同種の観測機を増やしたいこと、ESP32をBLEデバイスとして温湿度以外のセンサーにも対応できるようにしてみたいと考えています。

ネットに公開しても私以外が頻繁に見ることはないと思われるので、閲覧環境をネットに公開するかはまだ考えていません。

Discussion