本稿では、ESP32マイコンを使って温湿度を計測するBLE(Bluetooth Low Energy)デバイスを作り、ラズパイでBLEデバイスからの温湿度データを受け取ってクラウドサービスに送信するシステムの作り方を紹介します。BLEデバイスは電池で駆動させます。

概要

温湿度を計測する電池駆動のBLEデバイスを2つ作り、部屋の内外の温湿度を半年以上計測し続けることを目指します。システムの構成はBLEデバイスを2個とラズパイです。ラズパイはBLEデバイスとクラウドサービスのサーバーの間を取り持つゲートウェアの役割を果たします。クラウドサービスには、 Google Cloud Platform と、Ambient(IoTデータの可視化サービス)の両方を使ってみました。(どちらか一方で十分です)

BLEデバイスは、近くのホストに自分の存在を知らしめるアドバタイジングを行う際に送信パケットの中に温湿度データも乗せることで、ホスト(ラズパイ)にBLEデバイスとして認識されると同時に温湿度データも伝えます。

ラズパイは、BLEデバイスが周囲にいないかどうか常時スキャンし、意図したBLEデバイスを見つけたら温湿度データを受け取って、Google Cloud Platform とAmbient 上に作成したリソース上へ送信します。

Google Cloud Platform と Ambient に作成されたリソース上では、受け取った温湿度データが蓄積されていきます。

BLEデバイス(ペリフェラル)を作る

今回作成したBLEデバイスは、WiFiとBluetoothを備えた大人気のESPマイコンとDC-DCコンバーター、温湿度センサー、電池BOX付きケース、単三電池x3、及び配線で構成されています。

<主要部品リスト>

  • ESP32マイコン:秋月電子 ESP32-WROOM-32Eマイコンボード 【K-16108】
  • DC-DCコンバーター:TPS62742
  • 温湿度センサー:BME280
  • 電池BOX付きケース: WHガタ ボウスイハンドヘルドケース  WH145-33-M3-BN

仕様検討

当初、EPS32のWiFi機能を使って、つまりBLEデバイスとはせずWiFI通信機能を持った温湿度計測デバイスをと作るつもりでしたが、WiFI通信は消費電力がそれなりに高いということで、より消費電力の少ない BLE(Bluetooth Low Energy)機能を用いることにしました。

BLEデバイス間の通信は、ブロードキャストとコネクションという2つの方式が定義されていますが、コネクションを確立しなくても、ブロードキャスト(*1)により、今回やりたい温湿度データの送受を実現できますでこれを利用します。その方がBLEデバイス側の消費電力が小さく済みます。ペリフェラル側のBLEデバイスは温湿度データを乗せてブロードキャストし、受け取り側はこのブロードキャストのアドバタイジングを捕まえてデータを抜き取ります。このときデータの受け取り側はペリフェラル側のBLEデバイスとはコネクションを行いません。そのためペリフェラル側は、受け手に自分の存在を気づいてもらえたか、データを受け取ってもらえたかは分かりません。

*1: BLEデバイスが周囲にアドバタイジングパケットを飛ばすアドバタイジングと呼ばれる処理がある。このアドバタイジングパケットの中に僅かだが自由にデータを設定できる領域がある。

これは、例えるなら母親を探すアザラシが鳴いているのに気づいた母アザラシが子アザラシの存在確認だけで満足しコミュニケーションは行わないようなものです。子アザラシからは母アザラシを認識できません。鳴くだけです。(アドバタイジング) 例え話しついでに説明を加えます。子アザラシは延々鳴いては体力も気力も消耗しますし、体力に限界がある(電池駆動)ので、少し鳴いてしばらく寝る、起きて少し鳴いてしばらく寝る、これを繰り返すことで体力(電池)を温存することにしましょう。

ESP32には動作モードが複数あります。Deep Sleepモードで、かつULPをONしなければわずか10μAの消費電力で済みます。でも Deep Sleep 中は通信を行えません。よって、温湿度計測デバイスとの通信やアドバタイジングを通常モードで実行したら、Deep Sleepモードに移行して寝る(1分とか5分とか10分とか寝る)、それを繰り返す。そのような設計としました。

ハードウェア選び

たいていのESP32マイコン基板は、USBシリアル変換デバイスも一緒に実装されていて、これによってPCからプログラムを書き込んだり、シリアルモニタに出力が行えます。しかし、これはこれで電力を消費します。

WayinTop ESP32開発ボード

「WayinTop ESP32開発ボード」ではUSBシリアル変換デバイスに CP2102 が使われていますがデータシートによれば消費電力はサスペンド時に80μAになります。せっかくESP32マイコンをDeep Sleep Modeにしてもそれよりも消費電力が大きいですね。デバッグやプログラム書き込み時以外は使わないデバイスなのでそれによって電池が消費されるのは惜しいですね。

そこで、USBシリアル変換デバイスの載っていないEPS32ボードと、USBシリアル変換デバイス基板を探していたらちょうと良いのが見つかったのでこれらを利用することにしました。デバッグやプログラム書き込み時はこれら2つを接続して使います。

  • 秋月電子 ESP32-WROOM-32Eマイコンボード 【K-16108】
  • スイッチサイエンス  CP2102N USB-シリアル変換ボード(商品番号:6841)
ESP32-WROOM-32Eマイコンボード
CP2102N USB-シリアル変換ボード

続いて電池と電源をどうするかですが、単3電池×3本で3.6v~4.5v程度を用意し、低消費電力のDCDCコンバーターで3.3vを得ることにしました。この3.3vはESP32マイコンボード上の3.3v端子に接続します。(つまりESP32マイコンボード上の5v→3.3vレギュレーターは使用しません。)さて、電池ですが、この図からわかるとおり、ニッケル水素の充電池は乾電池に比べて公称電圧は低いものの、一定の電圧が長く続き、容量が無くなると一気に電圧が低下する放電特性が特徴です。これは電池駆動のBLEデバイスにとって良さそうです。今回、できるだけ長期間運転したいと思いますので、この充電池を使ってみます。(ただ、充電池は使用に際して注意点があるようです。過放電や充電池のショートによる発熱で電池内部からガスを放出するらしいので密閉ケースには不向きです。せっかく密閉度の高いBOXを用意したのですが密閉しないよう注意することにします。)

Panasonic webより

BLEデバイス(ペリフェラル)のソフトウェア

準備

ESP32マイコンのソフトウェア開発は、ArduinoのIDEを使うことができます。

その準備はこちらのサイトに詳しいです。”Arduino core for the ESP32 のインストール方法

ソフトウェア

ライブラリを活用することでソフトウェアはとても単純で少ないコードで作成できます。

Arduino系でおなじみのsetup()が最初に実行され、続いてloop()が実行されますが、今回はloop()の中は何も処理なしです。settup()だけが実行されます。settup()の中では、温湿度計測デバイスからデータを読み取ってBLEのアドバタイジングパケットにデータをセットしアドバタイジングを実行したら、所定の時間寝る処理を記述しています。所定の時間経過後起こされたら、ソフトウェアリセットがかかって、再びsettup()が実行されます。これを延々繰り返します。


#define DEBUG_MODE

#include <Wire.h>
// https://github.com/nkolban/ESP32_BLE_Arduino
#include <BLEDevice.h>
#include <BLEServer.h>

#include <esp_sleep.h>
// https://github.com/ks-tec/BME280_I2C 
#include <BME280_I2C.h>

#define SERIAL_BAUD_RATE  115200
#define BME280_I2C_SCL    22
#define BME280_I2C_SDA    21
// BME280_ADDRESS is already defined as 0x76 in BME280_I2C.h 
// 22 and 21 is GPIO number on target board.
// Create BME280 object
BME280_I2C BME280; 

#define DEVICE_NAME "ESP32_BLE"         // device name
#define DEVICE_NUMBER 1                 // server device idetify number(1~8)
// See the following for generating UUIDs:
// https://www.uuidgenerator.net/
#define SERVICE_UUID        "4800d65f-5ff8-4714-a945-ed9d30f02ab7"
#define CHARACTERISTIC_UUID "7e7c1652-4546-4226-ab01-3c706bd4e25d"

RTC_DATA_ATTR static uint8_t SEQ_NUM; // sequence number on RTC memory
const int SLEEP_TIME = 600;            // sleep time (second)
const int ADVERTISING_TIME = 300;       // advertising time (mili second)

void readBME280_setAdvertisementData(BLEAdvertising* pAdvertising)
{
  // set sea-level pressure
  BME280.setSeaLevelPressure(1010);
  // read values from BME280 and store calibrated values in the library
  // and the calibrated values is storeed to BME280.data in the library.
  // the stored data is temperature, atmospheric pressure, humidity and altitude.
  BME280.read();
  
  #ifdef DEBUG_MODE
  // format the stored values
    char weather_value[64];
    const char *comma = ",";
    char temp_c[12], humi_c[12], pres_c[12], altd_c[12];
    sprintf(temp_c, "%2.2lf", BME280.data.temperature);
    sprintf(humi_c, "%2.2lf", BME280.data.humidity);
    sprintf(pres_c, "%4.2lf", BME280.data.pressure);
    //sprintf(altd_c, "%4.2lf", BME280.data.altitude);
    sprintf(weather_value,"%s%s%s%s%s", temp_c, comma, humi_c, comma, pres_c); 
    Serial.println(weather_value);
  #endif
  double t = BME280.data.temperature;
  double h = BME280.data.humidity;
  double p = BME280.data.pressure;
  uint16_t temp = (uint16_t)(t * 100);  // need to divide with 100 at the client receiving this data
  uint16_t humi = (uint16_t)(h * 100);  // need to divide with 100 at the client receiving this data
  uint16_t pres = (uint16_t)(p * 50);  // need to divide with 50 at the client receiving this data
  
  // Make the send data for putting in the Advertising object.
    std::string strData = "";
    strData += (char)0xff;                      // Manufacturer specific data
    strData += (char)0xff;                      // manufacturer ID low byte
    strData += (char)0xff;                      // manufacturer ID high byte
    strData += (char)DEVICE_NUMBER;             // server divice identfy number 
    strData += (char)0x55;                      // dummy status data 
    strData += (char)SEQ_NUM;                   // sequence number  
    strData += (char)(temp & 0xff);           // Low byte of temperature data
    strData += (char)((temp >> 8) & 0xff);    // High byte of temperature data
    strData += (char)(humi & 0xff);           // High byte of humidity data
    strData += (char)((humi >> 8) & 0xff);    // High byte of humidity data
    strData += (char)(pres & 0xff);           // Low byte of pressure data
    strData += (char)((pres >> 8) & 0xff);    // High byte of pressure data
    strData += (char)0x34;                      // Low byte of dummy data
    strData += (char)0x12;                      // High byte of dummy data 
    strData = (char)strData.length() + strData; // Length of strData  

    // Set the device name, flags, and send data in the Advertising objedt
    BLEAdvertisementData AdvertisementData = BLEAdvertisementData();
    AdvertisementData.setName(DEVICE_NAME);
    AdvertisementData.setFlags(0x06);         
    AdvertisementData.addData(strData);
    pAdvertising->setAdvertisementData(AdvertisementData);
}


void setup() {
  // put your setup code here, to run once:
  Wire.begin();
  #ifdef DEBUG_MODE
    Serial.begin(SERIAL_BAUD_RATE);
    Serial.println("Bosch BME280 Temp,Humidity,Pressure Sensor");
  #endif
  
  // set address and connected pins
  BME280.setAddress(BME280_ADDRESS, BME280_I2C_SDA, BME280_I2C_SCL);

  // initialize BME280
  // each parameter uses the enum values ​​defined in the header file.
  // typically, SPI mode uses BME280_SPI3_DISABLE because it is for I2C.
  bool isStatus = BME280.begin(
      BME280.BME280_STANDBY_0_5,
      BME280.BME280_FILTER_16,
      BME280.BME280_SPI3_DISABLE,
      BME280.BME280_OVERSAMPLING_2,
      BME280.BME280_OVERSAMPLING_16,
      BME280.BME280_OVERSAMPLING_1,
      BME280.BME280_MODE_NORMAL);

  if (!isStatus)
  {
    #ifdef DEBUG_MODE
    Serial.println("can NOT initialize for using BME280.\n");
    #endif
  }
  else
  {
    #ifdef DEBUG_MODE
    Serial.println("ready to using BME280.\n");
    #endif
  }

  
  // initialize BLD device
  BLEDevice::init(DEVICE_NAME); 

  // BLEサーバーを作成してアドバタイズオブジェクトを取得する
  BLEServer *pServer = BLEDevice::createServer();
  BLEAdvertising *pAdvertising = pServer->getAdvertising();
  // 送信情報を設定してシーケンス番号をインクリメントする
  readBME280_setAdvertisementData(pAdvertising);
  SEQ_NUM++;

  // Advertising
  pAdvertising->start();
  #ifdef DEBUG_MODE
    Serial.println("Advertising!!!");
  #endif
  delay(ADVERTISING_TIME);
  pAdvertising->stop();

  #ifdef DEBUG_MODE
    Serial.println("Deep sleep mode!");
  #endif
  esp_deep_sleep(SLEEP_TIME * 1000000LL);
}

void loop() {
  // put your main code here, to run repeatedly:

}

ゲートウェイを作る

ゲートウェアの機能は以下のとおりです。

  • BLEデバイスをスキャンする
  • 目的のBLEデバイスを見つける
  • 目的のBLEデバイスのアドバタイジングパケットの中から温湿度データを抜き取る
  • 温湿度データを Google Cloud Platform とAmbient 上に作成したリソース上へ送信する

ゲートウェイはpythonのライブラリーを活用することと、WiFiとBLEのインターフェースを持つラズパイで簡単に実現できます。

Google Cloud PlatformのGoogole Drive上のSpread sheetを活用する方法についてはこちらのサイトを参考にしました。”ラズパイからPyhtonでGoogleスプレッドシートやドライブにアクセスする方法” 少し作業が面倒ですので、手っ取り早く取得したデータの見える化を行いたいなら、Ambientのほうが楽かもしれません。

Ambientの活用方法は、Ambientのサイトのドキュメントやチュートリアルを見るのが良いです。

from asyncio import exceptions
import json
from bluepy.btle import Scanner, DefaultDelegate, BTLEException
import ambient
import time
from httplib2 import Credentials
import requests

import json
import datetime
import gspread
from oauth2client.service_account import ServiceAccountCredentials

# key for using google service
SCOPE = ['https://spreadsheets.google.com/feeds','https://www.googleapis.com/auth/drive']
KEY_NAME = 'Google Cloud Platformの自身のプロジェクト上で作った秘密鍵ファイル(JSON等)へのパスを記述する'
SPREADSHEET_KEY = '温湿度データを追加書き込みする対象のGoogle spread sheetへのアクセスキーを記述する'

# key for seraching target name of  my devices
# アドバタイジングパケットの中で自身で定義したデータを記述(スキャンしてリスト化されたBLEデバイスの中から自分のBLEデバイスを見つけるための情報
target_device = {
    'ESP32_BLE':{'companyID':'ffff'}
    }
target = 'ESP32_BLE'
# key for using Ambient service
youserKey = "Ambientへのアクセスキーを記述する"
channelId = "チャンネルIDを記述する"
writeKey = "ライトアクセス用のキーを記述する"
using_ambient = True
Debugging = False
timeout = 12.0

class ScanDelegate(DefaultDelegate):
    def __init__(self):
        DefaultDelegate.__init__(self)

    def handleDiscovery(self, dev, isNewDev, isNewData):
        if Debugging :
            if isNewDev:
                print("Discovered device", dev.addr)
            elif isNewData:
                print("Received new data from", dev.addr)


def main():
    # Create contral google spread sheet
    _credentials = ServiceAccountCredentials.from_json_keyfile_name(KEY_NAME, SCOPE)
    _gcontrol = gspread.authorize(_credentials)
    worksheet = _gcontrol.open_by_key(SPREADSHEET_KEY).sheet1
    
    # Create instance for scan class of BLE device
    scanner = Scanner().withDelegate(ScanDelegate())
    # Create instance for Ambient service class 
    if using_ambient:
        amb = ambient.Ambient(channelId, writeKey)

    # Repeat BLE device scan and data transmission until key interrupt is entered.
    while True:
        global d1, d2, d3, d4, d5, d6, d7, d8,targetFlg
        targetFlg  = False
        try:
            devices = scanner.scan(timeout, passive=True)
            for device in devices:
                if Debugging:
                    print("Device %s (%s), RSSI=%d dB" % (device.addr, device.addrType, device.rssi))
                for (adtype, desc, value) in device.getScanData():
                    if Debugging:
                        print("  %s = %s" % (desc, value))
                    if target  in ('ESP32_BLE'):
                        if desc == 'Manufacturer' and value[0:4] == target_device[target]['companyID']:
                            deviceID =value[4:6]
                            status = value[6:8]
                            number = value[8:10]
                            temp =  value[12:14] + value[10:12]
                            humid = value[16:18] + value[14:16]
                            press = value[20:22] + value[18:20]
                            dummy = value[24:26] + value[22:24]
                            # Convert an unprefixed hexadecimal string to Int
                            int_number = int(number, 16)
                            float_temp = int(temp, 16) / 100.00
                            float_humid = int(humid, 16) / 100.00
                            float_press = int(press, 16) / 50.0
                            float_dummy = int(dummy, 16) / 100.00

                            targetFlg = True

                            print(deviceID, ',', status, ',', int_number, ',', float_temp, ',',
                                    float_humid, ',', float_press, ',', float_dummy, sep='')
                            today = datetime.datetime.now()
                            record = [today.strftime('%Y/%m/%d %H:%M'),deviceID, float_temp, float_humid, float_press]
                            worksheet.append_row(record)

                            if deviceID == "01":
                                d1 = float_temp
                                d2 = float_humid
                                d3 = float_press
                                d4 = float_dummy
                            elif deviceID == "02":
                                d5 = float_temp
                                d6 = float_humid
                                d7 = float_press
                                d8 = float_dummy
                            else:
                                print("devicID failed")        
            try:
                # Data from two devices are sent together.
                # d1-d2:for deviceID="01", d5-d8:for deviceID="02" 
                if targetFlg and using_ambient:
                    rtn = amb.send({'d1':d1, 'd2':d2, 'd3':d3, 'd4':d4,
                                    'd5':d5, 'd6':d6, 'd7':d7, 'd8':d8})
                    if Debugging :
                        print('sent data to Ambient (rtn = %d )' % rtn.status_code)
                    rtn.close
            except requests.exceptions.requests.RequestException as e:
                print('request failed', e)

        except BTLEException:
            print("BTLE Exception")

      
        time.sleep(0.1)

if __name__ == "__main__":
    main()

どんな感じに記録されるか

こんな感じです。

動作確認を容易にするために、1分ごとに計測と記録する仕様でテストしましたが、5分か10分の間隔に変更して、室内とベランダにBLEデバイスを設置して何日駆動できるか試します。ラズパイをずーっと動かし続けるわけですが、ログアウトしてもゲートウエイのプログラムを実行してくれるようにする必要があります。ゲートウエイのファイル名をscanner.pyとしたとき、以下のように記述して実行します。

sudo nohup python env2ambientBS.py < /dev/null &

さて、何日駆動できるでしょうか。時々、Google とAmbientの自身のリソース上をチェックしますか。