本記事は、仙台のメイカースペース"FabLab Senda FLAT"で、Arduino等を使いながらIoT的機器を作っている集まりで発表した内容をまとめたものです。
この集まりはハード寄りの人が多いため、スマホアプリのBLEの開発についてのさわりについて発表しました。
概要
M5StackのENV.Sencorから温度と気圧を読み取り、そのデータをBLEのAdvertisingで飛ばします。
BLEで飛ばした温度と気圧のデータをAndroidアプリで受け取り、アプリ上で表示します。
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言語で開発します。
開発環境にはVSCodeとPlatformIO IDEプラグインを使います。
PlatformIOとは
Arduino言語で開発する際には一般的にはArduino IDEが使われますが、開発になれてくるとArduino IDEで開発をするのはツラいものがあります。
PlatformIOはいろいろなマイコン+いろいろなフレームワークに対応した高機能エディタにプラグインできる組み込み用IDEです。
VSCodeの拡張としてインストールすることができ、簡単に開発を始めることができます。
PlatformIOはVSCodeの他にもAtom、Vim、CLionといったエディタにも対応しています。
VSCodeのインストール
VSCodeのサイトからダウンロードし、インストールして下さい。
VSCodeへのPlatformIO IDE拡張のインストール
VSCodeを起動し、
使用するライブラリのダウンロード
M5Stack開発用のライブラリと、ENV.Sensorのライブラリをインストールします。
M5Stack開発用のライブラリをインストールします。
VSCodeのPIO Homeのタブより
次にENV.Sensorのライブラリをインストールします。
BMP280とはENV.Sensorに搭載されている温度・気圧センサーのチップです。
データシート等はこちら。
プロジェクトの作成
プロジェクトを作成します。
VSCodeのPIO Homeのタブより
各種ファイルが生成されます。
M5Stackのセンサーの読み取りとBLEでのデータのアドバタイジング
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
を書き換えます。
<?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
を書き換えます。
(このファイルは画面のレイアウトを指定するためのファイルです)
<?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
を書き換えます。
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アプリを開発する際は工数等の見積もりには注意しましょう。
参考文献
M5Stack + BLE
PlatformIO
PlatformIO + M5Stack
Android + BLE