22
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

M5Stackで測定した温度と気圧をBLEで飛ばし、Androidアプリで受け取ってみる

Last updated at Posted at 2020-04-21

本記事は、仙台のメイカースペース"FabLab Senda FLAT"で、Arduino等を使いながらIoT的機器を作っている集まりで発表した内容をまとめたものです。
この集まりはハード寄りの人が多いため、スマホアプリのBLEの開発についてのさわりについて発表しました。

概要

M5StackのENV.Sencorから温度と気圧を読み取り、そのデータをBLEのAdvertisingで飛ばします。
BLEで飛ばした温度と気圧のデータをAndroidアプリで受け取り、アプリ上で表示します。

overview.png

BLEでは双方向通信のポイントトゥーポイントと単方向のブロードキャストモードがありますが、今回は簡単なブロードキャストモードモードのみを扱います。

ソースコード

送信側(M5Stack)

M5Stackとは

M5Stackは液晶ディスプレイ、microSDカードスロット、3つのボタン、USB、Groveのコネクタ5cm四方の基板に搭載しプラスチックのケースに詰め込んだモジュールです。
CPUにはEspressifのESP32が搭載されており、Wi-FiおよびBluetooth v4.2とBLEを使用することができます。

いろいろな拡張基板が公式・非公式を問わずに用意されています。

M5Stack上で動くソフトの開発はEspressifのESP-IDF(C/C++言語)のほか、Arduino IDEや、MicroPythonなどを利用できます。

このように、M5StackはIoT機器の試作にはとても重宝するモジュールです。

M5Stackの準備

本記事では、M5GOに付属の本体とENV.Sensorを使用します。
写真のように本体のPORT AにENV.Sensorを接続します。

M5Stackの開発環境の構築

今回はArduino言語で開発します。
開発環境にはVSCodePlatformIO IDEプラグインを使います。

PlatformIOとは

Arduino言語で開発する際には一般的にはArduino IDEが使われますが、開発になれてくるとArduino IDEで開発をするのはツラいものがあります。

PlatformIOはいろいろなマイコン+いろいろなフレームワークに対応した高機能エディタにプラグインできる組み込み用IDEです。
VSCodeの拡張としてインストールすることができ、簡単に開発を始めることができます。

PlatformIOはVSCodeの他にもAtom、Vim、CLionといったエディタにも対応しています。

VSCodeのインストール

VSCodeのサイトからダウンロードし、インストールして下さい。

VSCodeへのPlatformIO IDE拡張のインストール

VSCodeを起動し、

p1.png p2.png

使用するライブラリのダウンロード

M5Stack開発用のライブラリと、ENV.Sensorのライブラリをインストールします。

M5Stack開発用のライブラリをインストールします。
VSCodeのPIO Homeのタブより

次にENV.Sensorのライブラリをインストールします。

BMP280とはENV.Sensorに搭載されている温度・気圧センサーのチップです。
データシート等はこちら

プロジェクトの作成

プロジェクトを作成します。
VSCodeのPIO Homeのタブより

各種ファイルが生成されます。

M5Stackのセンサーの読み取りとBLEでのデータのアドバタイジング

src/main.cppを下記のように書き換えます。
処理の内容についてはコード内のコメントを参照してください。

src/main.cpp
#include <M5Stack.h>
#include <Adafruit_BMP280.h>

#include <BLEDevice.h>
#include <BLE2902.h>

// BLE上のこのデバイスの名前。適当な名前でOK。
#define BLE_LOCAL_NAME "M5GO Env.Sensor Advertiser"
// BLEのサービスUUID。適当なUUID(ランダムで生成したものがよい)を設定して下さい。
#define BLE_SERVICE_UUID "133fe8d4-5197-4675-9d76-d9bbf2450bb4"
// BLEのCharacteristic UUID。適当なUUID(ランダムで生成したものがよい)を設定して下さい。
#define BLE_CHARACTERISTIC_UUID "0fc10cb8-0518-40dd-b5c3-c4637815de40"

// BMP280のインスタンスを作成
static Adafruit_BMP280 bmp280;

static BLEServer* pBleServer = NULL;
static BLECharacteristic* pBleNotifyCharacteristic = NULL;

// 温度と気圧のデータを指定したバッファーに書き込みます。
// バッファーには、1〜4バイト目にはリトルエンディアンで温度を格納し、
// 5〜8バイト目にはリトルエンディアンで気圧を格納します。
// 
// - buffer uint8_tの8つ分以上のサイズのバッファを指定すること
static void pack(uint8_t* buffer, float temperature, float pressure) {
  int32_t temperature100 = (int32_t)(temperature * 100);
  buffer[0] = temperature100 & 0x000000ff;
  buffer[1] = (temperature100 & 0x0000ff00) >> 8;
  buffer[2] = (temperature100 & 0x00ff0000) >> 16;
  buffer[3] = (temperature100 & 0xff000000) >> 24;

  int32_t pressure100 = (int32_t)(pressure * 100);
  buffer[4] = pressure100 & 0x000000ff;
  buffer[5] = (pressure100 & 0x0000ff00) >> 8;
  buffer[6] = (pressure100 & 0x00ff0000) >> 16;
  buffer[7] = (pressure100 & 0xff000000) >> 24;
}

// 起動時に最初の1回だけ呼ばれる処理は setup 関数内に書きます
void setup() {
  // M5Stackの初期化
  M5.begin();

  // BMP280の初期化。
  // 初期化の結果が bmp280InitResult の変数に格納されます。
  // 引数に指定している BMP280_ADDRESS_ALT はBMP280センサーのI2Cアドレスです。
  bool bmp280InitResult = bmp280.begin(BMP280_ADDRESS_ALT);

  // M5StackのLCD(液晶ディスプレイ)を黒で塗りつぶします
  M5.Lcd.fillScreen(BLACK);
  // M5StackのLCDで表示する文字色を白、背景色を黒に設定します。
  M5.Lcd.setTextColor(WHITE ,BLACK);
  // M5StackのLCDで表示する文字の大きさを設定します。
  M5.Lcd.setTextSize(2);

  // "BMP280" という文字列をLCDの座標 (10, 20) の位置に表示します。
  // LCDの座標系は左上が(0, 0)、右下が(320, 240)です。
  M5.Lcd.setCursor(10, 20);
  M5.Lcd.print("BMP280");
  // "temperature:" という文字列をLCDの座標 (30, 50) の位置に表示します。
  M5.Lcd.setCursor(30, 50);
  M5.Lcd.print("temperature:");
  // "temperature:" という文字列をLCDの座標 (30, 80) の位置に表示します。
  M5.Lcd.setCursor(30, 80);
  M5.Lcd.print("pressure:");

  // BMP280の初期化が失敗した場合(bmp280InitResultがfalseの場合)は…
  if (!bmp280InitResult) {
    // M5StackのLCDで表示する文字色を黄色にします。
    M5.Lcd.setTextColor(YELLOW ,BLACK);
    // "Failed BMP280 init." という文字列をLCDの座標 (10, 200) の位置に表示します。
    M5.Lcd.setCursor(10, 200);
    M5.Lcd.print("Failed BMP280 init.");
  }

  // BLE環境の初期化
  BLEDevice::init(BLE_LOCAL_NAME);
  // BLEサーバの生成
  pBleServer = BLEDevice::createServer();
  // BLEのサービスの生成。引数でサービスUUIDを設定する。
  BLEService* pBleService = pBleServer->createService(BLE_SERVICE_UUID);
  // BLE Characteristicの生成
  pBleNotifyCharacteristic = pBleService->createCharacteristic(
                                // Characteristic UUIDを指定
                                BLE_CHARACTERISTIC_UUID,
                                // このCharacteristicのプロパティを設定
                                BLECharacteristic::PROPERTY_NOTIFY
                             );
  // BLE Characteristicにディスクリプタを設定
  pBleNotifyCharacteristic->addDescriptor(new BLE2902());
  // BLEサービスの開始
  pBleService->start();
  // BLEのアドバタイジングを開始
  pBleServer->getAdvertising()->start();
}

// 電源が入っている間は、この loop 関数で書いた処理が繰り返されます
void loop() {
  // M5StackのボタンA/B/Cの読み取り状態を更新しています。
  // ボタンを使わない場合でも、loop関数の冒頭で M5.update() を呼んでおくといいでしょう。
  M5.update();

  // BMP280から温度を取得します
  float temperature = bmp280.readTemperature();
  // BMP280から気圧を取得します。取得した気圧を100でわることで、hPaの単位になります。
  float pressure = bmp280.readPressure() / 100;

  // M5StackのLCDで表示する文字色を白、背景色を黒に設定します。
  M5.Lcd.setTextColor(WHITE ,BLACK);

  // LCDの座標 (180, 50) の位置に温度を小数二桁で表示します。
  M5.Lcd.setCursor(180, 50);
  M5.Lcd.printf("%.2fC", temperature);
  
  // LCDの座標 (180, 50) の位置に気圧を小数二桁で表示します。
  M5.Lcd.setCursor(180, 80);
  M5.Lcd.printf("%.2fhPa", pressure);

  // BLEでのデータ通知用バッファを定義
  uint8_t dataBuffer[8];
  // 温度と気圧のデータをdataBufferに格納します
  pack(dataBuffer, temperature, pressure);
  // データをBLEに設定し、送信します
  pBleNotifyCharacteristic->setValue(dataBuffer, 8);
  pBleNotifyCharacteristic->notify();

  // 33ミリ秒停止します
  delay(33);
}

プログラムの転送と実行

M5GO本体のUSB-CポートとPCを接続します。
そして、VSCodeの下部の「→」のをクリックするとプログラムが本体に転送され、本体でプログラムが実行されます。

本体の液晶ディスプレイに温度と気圧が表示されれば成功です。

受信側(Android)

Android端末(実機)の用意

Androidアプリの開発はPC上のAndroidエミュレータでもできますが、エミュレータではBLEがサポートされていませんので、BLEが搭載されたAndroid端末が必要です。

Androidアプリを実機で開発するには、実機で デバイスの開発者向けオプション を有効にする必要があります。
端末の [設定] > [デバイス情報] > [ビルド番号](端末によってメニュー階層が異なる場合があります) を7回タップすると デバイスの開発者向けオプション を有効にすることができます。

  • Android 7.1(API レベル 25)以下: [設定] > [デバイス情報] > [ビルド番号]
  • Android 8.0.0(API レベル 26)および Android 8.1.0(API レベル 26): [設定] > [システム] > [デバイス情報] > [ビルド番号]
  • Android 9(API レベル 28)以上: [設定] > [デバイス情報] > [ビルド番号]

デバイスの開発者向けオプション を有効にすると [設定] > [システム] > [詳細設定] > [開発者向けオプション](端末によってメニュー階層が異なる場合があります) のメニューが出現します。

Androidアプリ開発のためには、 **[設定] > [システム] > [詳細設定] > [開発者向けオプション]**でオプションをオンにし、 さらに[USBデバッグ] オプションもオンにする必要があります。

  • Android 7.1(API レベル 25)以下: [設定] > [開発者向けオプション] > [USBデバッグ]
  • Android 8.0.0(API レベル 26)および Android 8.1.0(API レベル 26): [設定] > [システム] > [開発者向けオプション] > [USBデバッグ]
  • Android 9(API レベル 28)以上: [設定] > [システム] > [詳細設定] > [開発者向けオプション] > [USBデバッグ]

詳しくはグーグルのドキュメントを参照して下さい。

Android Studioのインストール

Androidアプリの開発にはAndroid Studioを使用します。
Android Studioのインストールはグーグルのドキュメントと動画を参照して下さい。

プロジェクトの作成

Android Studioを起動し、

プロジェクトが作成されます。

Androidアプリでのデータの受け取りと表示

app/src/main/AndroidManifest.xml

app/src/main/AndroidManifest.xml を書き換えます。

app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<!-- NOTE: packageは書き換えないでください -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.github.yhirano.ble_mobile_app_sample.android">

    <!-- BLEを搭載した端末でのみインストールできるように設定 -->
    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

    <!-- BLEを使用するにはBluetoothのパーミッションが必要 -->
    <uses-permission android:name="android.permission.BLUETOOTH"/>
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
    <!--
      Android 6以降はBLEを使用するには位置情報取得のパーミッションが必要。
      Android 9以下は ACCESS_FINE_LOCATION の代わり にACCESS_COARSE_LOCATION でもOK。
     -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

app/src/main/res/layout/activity_main.xml

app/src/main/res/layout/activity_main.xml を書き換えます。
(このファイルは画面のレイアウトを指定するためのファイルです)

app/src/main/res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="センサーデータの表示欄"/>

</androidx.constraintlayout.widget.ConstraintLayout>

app/src/main/src/java/your-package-path/MainActivity.kt

app/src/main/src/java/your-package-path/MainActivity.kt を書き換えます。

app/src/main/src/java/your-package-path/MainActivity.kt
package com.github.yhirano.ble_mobile_app_sample.android // NOTE: packageは書き換えないでください

import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.*


class MainActivity : AppCompatActivity() {

    /** 温度と湿度を表示するTextView */
    private val sensorTextView by lazy {
        findViewById<TextView>(R.id.sensorTextView)
    }

    /** RSSI(電波強度)を表示するTextView */
    private val rssiTextView by lazy {
        findViewById<TextView>(R.id.rssiTextView)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (allPermissionsGranted()) {
            // 位置情報のパーミッションが取得できている場合は、BLEのスキャンを開始
            bleScanStart()
        } else {
            // 位置情報のパーミッションが取得できていない場合は、位置情報の取得のパーミッションの許可を求める
            ActivityCompat.requestPermissions(
                this,
                REQUIRED_PERMISSIONS,
                REQUEST_CODE_PERMISSIONS
            )
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                // 位置情報のパーミッションが取得できている場合は、BLEのスキャンを開始
                bleScanStart()
            } else {
                sensorTextView.text = "パーミッションが許可されていません"
                rssiTextView.text = null
            }
        }
    }

    /** REQUIRED_PERMISSIONSで指定したパーミッション全てが許可済みかを取得する */
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
    }

    /** BLEのスキャンを開始 */
    private fun bleScanStart() {
        val manager: BluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        val adapter = manager.adapter
        if (adapter == null) {
            sensorTextView.text = "Bluetoothがサポートされていません"
            rssiTextView.text = null
            return
        }
        if (!adapter.isEnabled) {
            sensorTextView.text = "Bluetoothの電源が入っていません"
            rssiTextView.text = null
            return
        }
        val bluetoothLeScanner = adapter.bluetoothLeScanner
        // "M5GO Env.Sensor Advertiser" というデバイス名のみの通知を受け取るように設定
        val scanFilter = ScanFilter.Builder()
            .setDeviceName("M5GO Env.Sensor Advertiser")
            .build()
        val scanSettings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_BALANCED)
            .build()
        Log.d(TAG, "Start BLE scan.")
        bluetoothLeScanner.startScan(listOf(scanFilter), scanSettings, scanCallback)
    }

    /** スキャンでデバイスが見つかった際のコールバック */
    private val scanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            super.onScanResult(callbackType, result)

            rssiTextView.text = "RSSI(受信信号強度) ${result.rssi}"

            // デバイスのGattサーバに接続
            val bluetoothGatt = result.device.connectGatt(this@MainActivity, false, gattCallback)
            val resultConnectGatt = bluetoothGatt.connect()
            if (resultConnectGatt) {
                Log.d(TAG, "Success to connect gatt.")
            } else {
                Log.w(TAG, "Failed to connect gatt.")
            }
        }
    }

    /** デバイスのGattサーバに接続された際のコールバック */
    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
            super.onConnectionStateChange(gatt, status, newState)

            if (gatt == null) {
                Log.w(TAG, "Gatt is empty. Maybe Bluetooth adapter not initialized.")
                return
            }

            if (newState == BluetoothGatt.STATE_CONNECTED) {
                Log.d(TAG, "Discover services.")
                // GATTサーバのサービスを探索する。
                // サービスが見つかったら onServicesDiscovered が呼ばれる。
                gatt.discoverServices()
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
            super.onServicesDiscovered(gatt, status)

            Log.d(TAG, "Services discovered.")

            if (status == BluetoothGatt.GATT_SUCCESS) {
                if (gatt == null) {
                    Log.w(TAG, "Gatt is empty. Maybe Bluetooth adapter not initialized.")
                    return
                }
                val service = gatt.getService(BLE_SERVICE_UUID)
                val characteristic = service?.getCharacteristic(BLE_CHARACTERISTIC_UUID)
                if (characteristic == null) {
                    Log.w(TAG, "Characteristic is empty. Maybe Bluetooth adapter not initialized.")
                    return
                }

                // Characteristic "0fc10cb8-0518-40dd-b5c3-c4637815de40" のNotifyを監視する。
                // 変化があったら onCharacteristicChanged が呼ばれる。
                gatt.setCharacteristicNotification(characteristic, true)
                val descriptor = characteristic.getDescriptor(
                    UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
                )
                descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
                gatt.writeDescriptor(descriptor)
            }
        }

        override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
            super.onCharacteristicChanged(gatt, characteristic)

            Log.v(TAG, "onCharacteristicChanged")

            this@MainActivity.runOnUiThread {
                val data = Data.parse(characteristic?.value) ?: return@runOnUiThread

                val sb = StringBuilder()
                sb.append("Temperature: ${String.format("%.2f", data.temperature)}\n")
                sb.append("Pressure: ${String.format("%.2f", data.pressure)}")
                sensorTextView.text = sb.toString()
            }
        }
    }

    /** 温度と気圧を持つデータクラス */
    private data class Data(val temperature: Float, val pressure: Float) {
        companion object {
            /**
             * BLEから飛んできたデータをDataクラスにパースする
             */
            fun parse(data: ByteArray?): Data? {
                if (data == null || data.size < 8) {
                    return null
                }

                val temperatureBytes = ByteBuffer.wrap(data, 0, 4)
                val pressureBytes = ByteBuffer.wrap(data, 4, 4)

                val temperature = temperatureBytes.order(ByteOrder.LITTLE_ENDIAN).int.toFloat() / 100.toFloat()
                val pressure = pressureBytes.order(ByteOrder.LITTLE_ENDIAN).int.toFloat() / 100.toFloat()
                return Data(temperature, pressure)
            }
        }
    }

    companion object {
        private val TAG = MainActivity::class.java.simpleName

        private const val REQUEST_CODE_PERMISSIONS = 10

        private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)

        /** BLEのサービスUUID */
        private val BLE_SERVICE_UUID = UUID.fromString("133fe8d4-5197-4675-9d76-d9bbf2450bb4")

        /** BLEのCharacteristic UUID */
        private val BLE_CHARACTERISTIC_UUID = UUID.fromString("0fc10cb8-0518-40dd-b5c3-c4637815de40")
    }
}

アプリの実行

アプリを実行すると、M5Stackから飛んできた温度・気圧のデータを受け取り、画面に温度と気圧を表示します。

BLEを使うアプリ開発での気にすべきこと

BLEを搭載していない端末のサポートをする

BLEを搭載していない端末のサポートが必要です。
本記事のアプリのようにBLEがない端末でインストールできないようにするか、BLEがない端末で動くように設計をする必要があります。
最近ではBLEがない端末は見なくなったので、BLEがない端末にはインストールできないようにするのもよいかもしれません。

AndroidアプリでBLEを使用する場合はたくさんの端末でテストする

Android端末によってはBluetoothの挙動が異なっていることが結構あるので、可能な限りいろいろなOSバージョンと端末で確認することをおすすめします。(不特定多数の端末で使うアプリの場合は特に)

iOSアプリは、最新のiOSバージョンから1〜2つ前のバージョンをサポートしておけば市場的には充分で、かつ端末の種類もAndroidに比べれば少ないので、BLEを使うアプリを開発するならiOSの方が楽です。

AndroidのBLEのAPIはひどい

AndroidのBLEのAPIはひどいと評判です。実際書いててツラいものがあります。
BLEを使うAndroidアプリを開発する際は工数等の見積もりには注意しましょう。

Android BLE ひどい - Google 検索

参考文献

M5Stack + BLE

PlatformIO

PlatformIO + M5Stack

Android + BLE

22
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?