Developer's Blog

【連載】Bluetooth LE (5) Android 4.3 で Bluetooth LE 機器を使う

Nexus4とSensorTag

こんにちは。共同開発部の北川です。

Bluetooth LE (以降 BLE) 連載第5回です。連載第4回まで iOS アプリと Windows ストアアプリから BLE 機器を扱いました。
第5回は Android 4.3 端末から BLE 機器へ接続します。

Bluetooth パッケージ

BLE 機器を扱うクラスは android.bluetooth パッケージにまとめられています。

android.bluetooth パッケージは Android 2.0 (API Level5)から存在しますが、 Android 4.3 (API Level18) から BLE を扱うクラスが追加されています。主に使用するクラスは次の通りです。

BluetoothManager

API Level18 から導入されました。

端末の Bluetooth 機能へのアクセスを提供します。BluetoothManager を通して BluetoothAdapter を取得します。

BluetoothAdapter

端末の Bluetooth 機能を制御します。BluetoothAdapter から BLE 機器のスキャンを指示します。

BluetoothGatt

API Level18 から導入されました。

BLE GATT プロファイルの API です。GATT サービスの検索、キャラクタリスティックの読み書き、ディスクリプタの読み書き、GATT プロファイルの接続と切断などが可能です。

BluetoothDevice

Bluetooth 機器を表します。機器のアドレス、機器名などを含みます。BluetoothAdapter からのスキャンの結果として受け取ります。

BluetoothGattService

API Level18 から導入されました。

GATT サービスを表します。サービスが提供するキャラクタリスティックを含みます。

BluetoothGattCharacteristic と BluetoothGattDescriptor

API Level18 から導入されました。

GATT キャラクタリスティックと GATT ディスクリプタを表します。値の読み書きの対象となります。

サンプルアプリ概要

連載第2回の iOS アプリと同等の機能を実装します。SensorTag と接続し、ボタンの状態をリアルタイムで読み取ります。

実装にあたり以下の環境を使用します。Android 4.3 以上であり BLE 対応の端末でなければ動作しません。

  • 端末:Nexus4 LG-E960 (2012, Android4.3)
  • SDK:Android 4.3 Platform (API Level 18)
  • BLE機器:Texas Instruments CC2541 SensorTag 開発キット

サンプルアプリの画面はこのようになっています。

Android BLESample

SensorTag を接続し、SensorTag の右のボタンを押すと以下のようになります。

Android BLESample

実装

実装のステップは次の通りです。

  • Bluetooth を利用可能であることを確認する
  • 機器を検索する
  • 機器と接続する
  • サービスを検索する
  • Characteristic を取得し Notification の受信要求を設定する
  • Notification を受信する

機器の検索や値の読み込み、設定などの BLE 機器に対する操作はすべて非同期呼び出しとなっており、結果はコールバックで受け取る実装となります。

すべてのコードを BleActivity に実装しています。サンプルコード全体の実装は本稿の最後に掲載しています。

Bluetooth の利用可否

AndroidManifest.xml へ Bluetooth のパーミッションを追加します。BLUETOOTH パーミッションは BLE 機器との通信に、BLUETOOTH_ADMIN は BLE 機器の検索に必要です。

                                                                                                                                                                                                                                                                        
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>

まずは BluetoothAdapter を取得します。また、Bluetooth が無効になっていると通信できません。端末の Bluetooth 設定が有効であるかは BluetoothAdapter.isEnabled() で判定します。Bluetooth 設定が無効の場合には Bluetooth を有効にするためのダイアログを表示します。

BluetoothManager manager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
mBluetoothAdapter = manager.getAdapter();

if (mBluetoothAdapter == null || !mBluetoothAdapter.isEnabled()) {
    Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
    startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT);
}

機器を検索

BLE 機器を検索します。機器の検索は BluetoothAdapter.startLeScan(BluetoothAdapter.LeScanCallback callback) で開始します。

startLeScan() の引数にはスキャン結果を処理する LeScanCallback の実装を指定します。

BLE 機器のスキャンは電力消費量が大きいため、必ずタイムアウトが必要です。startLeScan() にはタイムアウトの設定がありません。Handler を用いてタイムアウトを実現します。

/** BLE 機器検索のタイムアウト(ミリ秒) */
private static final long SCAN_PERIOD = 10000;

/** BLE機器を検索する */
private void connect() {
    mHandler.postDelayed(new Runnable() {
        @Override
        public void run() {
            // タイムアウト
            mBluetoothAdapter.stopLeScan(BleActivity.this);
        }
    }, SCAN_PERIOD);

    // スキャン開始
    mBluetoothAdapter.startLeScan(this);
}

機器との接続

スキャンにより BLE 機器が見つかり次第、順次 LeScanCallback.onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) が呼ばれます。今回は機器名が “SensorTag” であるものを探しています。消費電力を抑えるため、対象の機器を見つけたらすぐにスキャンを停止します。

対象の機器に対して BluetoothDevice.connectGatt(Context context, boolean autoConnect, BluetoothGattCallback callback) で接続します。autoConnect を false で呼び出すとすぐに機器への接続を開始します。接続完了や機器からのデータ受信は BluetoothGattCallback で処理することになります。

@Override
public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) {
    Log.d(TAG, "device found: " + device.getName());
    if ("SensorTag".equals(device.getName())) {
        // 機器名が "SensorTag" であるものを探す

        // 機器が見つかればすぐにスキャンを停止する
        mBluetoothAdapter.stopLeScan(this);

        // 機器への接続を試みる
        mBluetoothGatt = device.connectGatt(getApplicationContext(), false, mBluetoothGattCallback);

        // 接続の成否は mBluetoothGattCallback で受け取る
    }
}

サービスを検索

connectGatt() の結果として接続が確立すると BluetoothGattCallback.onConnectionStateChange(BluetoothGatt gatt, int status, int newState) が呼ばれます。newState が BluetoothProfile.STATE_CONNECTED であれば正常に接続できています。次のステップとして BluetoothGatt.discoverServices() を実行し、サービスを検索します。

@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
    Log.d(TAG, "onConnectionStateChange: " + status + " -> " + newState);
    if (newState == BluetoothProfile.STATE_CONNECTED) {
        // GATT接続成功
        // Serviceを検索する
        gatt.discoverServices();

        // Service検索の成否は mBluetoothGattCallback.onServiceDiscovered で受け取る
    } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
        // GATT通信が切断された
        mBluetoothGatt = null;
    }
}

Characteristic を取得し Notification の受信要求を設定

discoverServices() により開始したサービスの検索が完了すると、BluetoothGattCallback.onServicesDiscovered(BluetoothGatt gatt, int status) が呼ばれます。連載第2回の iOS アプリでは次の手順として Characteristic の検索がありましたが、Android ではサービスの検索時に Characteristic と Descriptor も取得しています。ここまできて正常にサービスの検索結果を受け取れば、すでに Characteristic へのアクセスが可能です。

対象のサービスと Characteristic を取得できていることを確認したら、Characteristic に対して Notification の受信要求を設定します。

Characteristic と Descriptor の読み書きは BluetoothGatt を通して行います。Notification の受信要求は BluetoothGatt.setCharacteristicNotification(BluetoothGattCharacteristic characteristic, boolean enable) で設定します。

ただし、上記メソッドだけでは Notification を取得することはできません。BLE 機器の Notification 機能を有効化するため、対象の Characteristic の Descriptor に対して BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE を設定します。これを設定をしなければ Notification を受け取ることができないため、忘れずに設定します。Descriptor の UUID は “00002902-0000-1000-8000-00805f9b34fb” 固定です。この値はAndroid SDK サンプルプロジェクト(BluetoothLeGatt)SampleGattAttributes.CLIENT_CHARACTERISTIC_CONFIG に定義されていたものを持ってきています。

/** 対象のサービスUUID (SensorTagのボタンサービスを指定しています) */
private static final String DEVICE_BUTTON_SENSOR_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb";
/** 対象のキャラクタリスティックUUID (SensorTagのボタン通知のキャラクタリスティックを指定しています) */
private static final String DEVICE_BUTTON_SENSOR_CHARACTERISTIC_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb";
/** キャラクタリスティック設定UUID (BluetoothLeGattプロジェクト、SampleGattAttributes.CLIENT_CHARACTERISTIC_CONFIGより */
private static final String CLIENT_CHARACTERISTIC_CONFIG = "00002902-0000-1000-8000-00805f9b34fb";
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
    Log.d(TAG, "onServicesDiscovered received: " + status);
    if (status == BluetoothGatt.GATT_SUCCESS) {
        BluetoothGattService service = gatt.getService(UUID.fromString(DEVICE_BUTTON_SENSOR_SERVICE_UUID));
        if (service == null) {
            // サービスが見つからなかった
        } else {
            // サービスを見つけた
            BluetoothGattCharacteristic characteristic =
                    service.getCharacteristic(UUID.fromString(DEVICE_BUTTON_SENSOR_CHARACTERISTIC_UUID));
            if (characteristic == null) {
                // キャラクタリスティックが見つからなかった
            } else {
                // キャラクタリスティックを見つけた

                // Notification を要求する
                boolean registered = gatt.setCharacteristicNotification(characteristic, true);

                // Characteristic の Notification 有効化
                BluetoothGattDescriptor descriptor = characteristic.getDescriptor(
                        UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG));
                descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
                gatt.writeDescriptor(descriptor);

                if (registered) {
                    // Characteristics通知設定が成功
                } else {
                    // Characteristics通知設定が失敗
                }
            }
        }
    }
}

Notification を受信しボタンの状態を取得

これでようやく SensorTag の状態を取得可能となりました。SensorTag の左右のボタンが押されたときと離されたときに BluetoothGattCallback.onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) が呼ばれます。引数の characteristic に通知された値が保持されていますので、必要に応じて処理をします。サンプルアプリでは SensorTag のボタンの状態をアプリ画面上に反映しています。


@Override
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
Log.d(TAG, “onCharacteristicChanged”);
// Characteristicの値更新通知

if (DEVICE_BUTTON_SENSOR_CHARACTERISTIC_UUID.equals(characteristic.getUuid().toString())) {
// ボタン状態の通知である

// SensorTag からは 2bit の値が渡される
Byte value = characteristic.getValue()[0];
boolean left = (0 < (value & 0x02)); boolean right = (0 < (value & 0x01)); // characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0); // や // characteristic.getStringValue(0); // でも値を取得可能です // 画面表示を更新する updateButtonState(left, right); // UI スレッドから表示を更新するメソッドを定義しています } } [/java]

今回は使用していませんが、Characteristic の値を単純に読み書きするには、BluetoothGatt の readCharacteristic()/writeCharacteristic() を呼び出した後で BluetoothGattCallback のonCharacteristicRead()/onCharacteristicWrite() から結果を受け取ります。

その他の補足事項

本稿で紹介した全体の実装は本稿の最後に掲載しています。この実装ではすべての機能を Activity に実装していますが、BLE 機器との接続は画面単位ではなくアプリ単位で管理する場合がほとんどと思います。本来であれば BLE 機器の管理は Service として実装すると実用的です。Service としての実装は Android SDK サンプルプロジェクトの BluetoothLeGatt が参考になります。

また、エラーチェックは最低限の実装となっています。機器やサービスのスキャンは厳密にする必要があります。必要に応じてエラーチェックを実装してください。

機器との接続が確立できない、またはサービスの検索が終わらないという場合には Android 端末の Bluetooth 設定をオフにしてから、再度オンにしてみてください。Nexus 4 でも Bluetooth の状態が不安定となることがあるようです。

参考リンク

まとめ

iOS、Android、Windows8.1 で BLE が使えるようになり、BLE 機器が増えていくと思われます。Android では NFC、WiFi Direct、BLE と近距離通信の選択肢が豊富です。状況に応じて最適な選択肢を選びましょう。

サンプルコード

本稿で実装したアプリの全体を掲載します。端末の Bluetooth 設定の確認は省き、接続状態の画面表示機能を追加しています。BLE 機器との接続解除処理も実装しています。

main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:padding="20dp">

    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Status:"
            android:layout_alignParentLeft="true"
            android:layout_marginLeft="0dp"
            android:layout_alignParentTop="true"
            android:layout_marginTop="0dp"
            android:id="@+id/textView"/>

    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="disconnected"
            android:id="@+id/text_status"
            android:layout_alignParentTop="true"
            android:layout_toRightOf="@+id/textView"
            android:layout_marginLeft="10dp"/>

    <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Connect"
            android:id="@+id/btn_connect"
            android:layout_marginTop="10dp"
            android:layout_below="@id/textView"
            android:layout_alignLeft="@id/textView"/>

    <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Disconnect"
            android:id="@+id/btn_disconnect"
            android:layout_alignTop="@+id/btn_connect"
            android:layout_toRightOf="@id/btn_connect"/>

    <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/btn_connect"
            android:layout_centerHorizontal="true"
            android:layout_marginTop="46dp">

        <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textAppearance="?android:attr/textAppearanceMedium"
                android:text="LEFT"
                android:id="@+id/left"
                android:layout_gravity="center"
                android:padding="20dp"/>

        <View android:layout_width="60dp"
              android:layout_height="match_parent"/>

        <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textAppearance="?android:attr/textAppearanceMedium"
                android:text="RIGHT"
                android:id="@+id/right"
                android:layout_gravity="center"
                android:padding="20dp"/>
    </LinearLayout>
</RelativeLayout>

BleActivity.java


package jp.co.fenrir.BLESample;

import android.app.Activity;
import android.bluetooth.*;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import jp.co.fenrir.BleSample.R;

import java.util.UUID;

public class BleActivity extends Activity implements BluetoothAdapter.LeScanCallback {
/** BLE 機器スキャンタイムアウト (ミリ秒) */
private static final long SCAN_PERIOD = 10000;
/** 検索機器の機器名 */
private static final String DEVICE_NAME = “SensorTag”;
/** 対象のサービスUUID */
private static final String DEVICE_BUTTON_SENSOR_SERVICE_UUID = “0000ffe0-0000-1000-8000-00805f9b34fb”;
/** 対象のキャラクタリスティックUUID */
private static final String DEVICE_BUTTON_SENSOR_CHARACTERISTIC_UUID = “0000ffe1-0000-1000-8000-00805f9b34fb”;
/** キャラクタリスティック設定UUID */
private static final String CLIENT_CHARACTERISTIC_CONFIG = “00002902-0000-1000-8000-00805f9b34fb”;

private static final String TAG = “BLESample”;
private BleStatus mStatus = BleStatus.DISCONNECTED;
private Handler mHandler;
private BluetoothAdapter mBluetoothAdapter;
private BluetoothManager mBluetoothManager;
private BluetoothGatt mBluetoothGatt;
private TextView mStatusText;

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);

mBluetoothManager = (BluetoothManager)getSystemService(BLUETOOTH_SERVICE);
mBluetoothAdapter = mBluetoothManager.getAdapter();

findViewById(R.id.btn_connect).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
connect();
}
});
findViewById(R.id.btn_disconnect).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
disconnect();
}
});

mStatusText = (TextView)findViewById(R.id.text_status);

mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
mStatusText.setText(((BleStatus) msg.obj).name());
}
};
}

/** BLE機器を検索する */
private void connect() {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
mBluetoothAdapter.stopLeScan(BleActivity.this);
if (BleStatus.SCANNING.equals(mStatus)) {
setStatus(BleStatus.SCAN_FAILED);
}
}
}, SCAN_PERIOD);

mBluetoothAdapter.stopLeScan(this);
mBluetoothAdapter.startLeScan(this);
setStatus(BleStatus.SCANNING);
}

/** BLE 機器との接続を解除する */
private void disconnect() {
if (mBluetoothGatt != null) {
mBluetoothGatt.close();
mBluetoothGatt = null;
setStatus(BleStatus.CLOSED);
}
}

@Override
public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) {
Log.d(TAG, “device found: ” + device.getName());
if (DEVICE_NAME.equals(device.getName())) {
setStatus(BleStatus.DEVICE_FOUND);

// 省電力のためスキャンを停止する
mBluetoothAdapter.stopLeScan(this);

// GATT接続を試みる
mBluetoothGatt = device.connectGatt(this, false, mBluetoothGattCallback);
}
}

private final BluetoothGattCallback mBluetoothGattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
Log.d(TAG, “onConnectionStateChange: ” + status + ” -> ” + newState);
if (newState == BluetoothProfile.STATE_CONNECTED) {
// GATTへ接続成功
// サービスを検索する
gatt.discoverServices();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
// GATT通信から切断された
setStatus(BleStatus.DISCONNECTED);
mBluetoothGatt = null;
}
}

@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
Log.d(TAG, “onServicesDiscovered received: ” + status);
if (status == BluetoothGatt.GATT_SUCCESS) {
BluetoothGattService service = gatt.getService(UUID.fromString(DEVICE_BUTTON_SENSOR_SERVICE_UUID));
if (service == null) {
// サービスが見つからなかった
setStatus(BleStatus.SERVICE_NOT_FOUND);
} else {
// サービスを見つけた
setStatus(BleStatus.SERVICE_FOUND);

BluetoothGattCharacteristic characteristic =
service.getCharacteristic(UUID.fromString(DEVICE_BUTTON_SENSOR_CHARACTERISTIC_UUID));

if (characteristic == null) {
// キャラクタリスティックが見つからなかった
setStatus(BleStatus.CHARACTERISTIC_NOT_FOUND);
} else {
// キャラクタリスティックを見つけた

// Notification を要求する
boolean registered = gatt.setCharacteristicNotification(characteristic, true);

// Characteristic の Notification 有効化
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(
UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG));
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);

if (registered) {
// Characteristics通知設定完了
setStatus(BleStatus.NOTIFICATION_REGISTERED);
} else {
setStatus(BleStatus.NOTIFICATION_REGISTER_FAILED);
}
}
}
}
}

@Override
public void onCharacteristicRead(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic,
int status) {
Log.d(TAG, “onCharacteristicRead: ” + status);
if (status == BluetoothGatt.GATT_SUCCESS) {
// READ成功
}
}

@Override
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
Log.d(TAG, “onCharacteristicChanged”);
// Characteristicの値更新通知

if (DEVICE_BUTTON_SENSOR_CHARACTERISTIC_UUID.equals(characteristic.getUuid().toString())) {
Byte value = characteristic.getValue()[0];
boolean left = (0 < (value & 0x02)); boolean right = (0 < (value & 0x01)); updateButtonState(left, right); } } }; private void updateButtonState(final boolean left, final boolean right) { runOnUiThread(new Runnable() { @Override public void run() { View leftView = findViewById(R.id.left); View rightView = findViewById(R.id.right); leftView.setBackgroundColor( (left ? Color.BLUE : Color.TRANSPARENT) ); rightView.setBackgroundColor( (right ? Color.BLUE : Color.TRANSPARENT) ); } }); } private void setStatus(BleStatus status) { mStatus = status; mHandler.sendMessage(status.message()); } private enum BleStatus { DISCONNECTED, SCANNING, SCAN_FAILED, DEVICE_FOUND, SERVICE_NOT_FOUND, SERVICE_FOUND, CHARACTERISTIC_NOT_FOUND, NOTIFICATION_REGISTERED, NOTIFICATION_REGISTER_FAILED, CLOSED ; public Message message() { Message message = new Message(); message.obj = this; return message; } } } [/java]

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

Copyright © 2019 Fenrir Inc. All rights reserved.