2015年6月7日 星期日

Android 讀取低功耗藍牙(BLE)程式初探

之前在Raspberry Pi 藍牙4.0應用之iBeacon 發射器這篇說明過藍牙在4.0版本中定義了「高速藍牙」、「傳統藍牙」和「低功耗藍牙」三種模式。
高速藍牙主攻資料交換與傳輸;傳統藍牙則以資訊溝通、裝置連線為重點;藍牙低功耗顧名思義,以不需佔用太多頻寬的裝置連線為主。
本篇文章來探討一下如何透過Android寫出讀取BLE資訊的程式。
圖片來源:O'Reilly Getting Started with Bluetooth Low Energy

實作環境:
Samsung Galaxy Note 3/4 (手機/平板請先確認有支援BLE)
TI CC2541 SensorTag
Eclipse LUNA 4.4.2 + ADT 23.0.6
Win8.1專業版
Java 1.8.0_40

說明:
TI CC2541 SensorTag是德州儀器的一項藍牙低功耗產品,其擁有溫度、濕度、壓力、加速度計、陀螺儀和磁力儀等6個感測器。
CC2541屬於2012年舊的產品,TI官網上目前販售同類的產品名稱叫做CC2650STK(美金29元)


實作目的:
從Samsung Galaxy Note 4手機透過BLE協定讀取TI CC2541 SensorTag上的溫度數值。
註:由於本篇主要是以Android讀取BLE裝置程式為主,並不是探討TI CC2541 SensorTag,礙於篇幅相關資料請到TI官網或是 SensorTag User Guide上查詢。

BLE基本概念:
在寫程式前我們必須了解一些BLE的概念,才能將程式一步步建構出來,首先就是BLE protocol stack。
BLE protocol stack分為Control、Host及Application三層(見下圖所示)。
圖片來源:O'Reilly Getting Started with Bluetooth Low Energy
Physical Layer (物理層) 及 Link Layer(鏈路控制層) 這個部份觀念類似網路的OSI模型七層中的第一層級第二層,由於本篇不是要探討BLE硬體,所以這部分因篇幅關係就不詳述。
Control層與Host層中間有一到 HCI 主要為提供標準藍牙事件及通知層。
Logical Link Control and Adaption Protocol:負責連接和事件。
Security Manager:配對、加密管理。

Attribute Protocol (ATT):所有資料傳輸經過這層實現,定義了Client和Server屬性;Client就傳Request,Server傳response。每個屬性都有一個唯一的UUID,屬性將以characteristics and services的形式傳輸。
Generic Access Prifile:設備搜尋、連接建立(GAP),定義了Role、Modes、Procedures及Security。
Generic Attribute Profile (GATT):規定了在service中使用ATT的方法,所有LE profile都必須基於GATT協定。
GATT中定義ATT層的Service (服務)與Characteristics (特徵)兩個屬性,每個BLE裝置都會定義出不同的服務與特徵屬性,除了硬體商自行定義以外;常見的BLE設備例如血壓計、心跳帶等等在藍牙官方都有明確定義好相關的GATT Specifications

App層:一些PROFILE和一些應用組成。其中之一就是本篇後面會探討的Android程式應用。

上述 Attribute Protocol (ATT)及 Generic Attribute Profile (GATT)是BLE全新的核心協定,GATT是架構在ATT之上,在與BLE設備進行溝通主要是透過這兩項協議。
以本篇實作所用的BLE裝置TI CC2541 SensorTag來說,它定義了不少服務(Service),所有的Service會用UUID = 0x2800 定義服務的起點。
SensorTag attribute table中我們可以查到:

0x1 Generic Access "00001800-0000-1000-8000-00805f9b34fb"
0xC Generic Attribute "00001801-0000-1000-8000-00805f9b34fb"
0x10 Device Information "0000180A-0000-1000-8000-00805f9b34fb"

0x23 IR Temperature "f000aa00-0451-4000-b000-000000000000"
0x2E Accelerometer "f000aa10-0451-4000-b000-000000000000"
0x39 Humidity "f000aa20-0451-4000-b000-000000000000"
0x44 Magnetometer "f000aa30-0451-4000-b000-000000000000"
0x4F Barometer "f000aa40-0451-4000-b000-000000000000"
0x5E Gyroscope "f000aa50-0451-4000-b000-000000000000"
0x69 Key Service "0000ffe0-0000-1000-8000-00805f9b34fb"
0x6E Test "F000AA60-0451-4000-B000-000000000000"
其他還有 0x75 、 0x80 等。

然而在在Service中會找到一個UUID是 0x2803,這個項目定義了characteristics (特徵)。 Characteristic可以理解為一個資料類型,它包括一個value和0至多個項目value的描述(Descriptor)。
不同的BLE裝置在相關原廠或是開發商會提供 attribute table (屬性表),屬性表中可以看到對於特徵如範圍、計量單位等描述(Descriptor)。
本篇所用的TI CC2541 SensorTag設備以IR Temperature Service為範例,可以找到兩個 0x2803 的特徵各代表IR Temperature Data 及 IR Temperature Config。

資料來源:TI Development KIT。(SensorTag attribute table

從上述表格中,必須注意到GATT權限,這權限包括:
None : 該屬性無法讀出或寫入由客戶端。
Readable : 該屬性可以由客戶端讀取(可讀)。
Writable : 該屬性可以被寫入由客戶端(可寫)。
Readable and writable : 所述屬性可以是讀取和寫入由客戶端。

以上表為例Type 0xAA01 僅能讀取溫度用,0xAA02 可讀寫,也就是可以查設定狀態也可以寫入一值讓他進入休眠省電狀態。
整體而言,每個服務中包含多個特徵。每個特徵會有一個property/value以及幾個descriptor(見下圖所示)。
由於篇幅所限,至此如果還不清楚BLE基本觀念的話,建議可以參考O'Reilly所出版的 Getting Started with Bluetooth Low Energy,這本書清楚的寫出了BLE一些基出觀念。
然而,在確實明白了上述概念後,接著就開始撰寫Androdi程式。

在寫程式前我們要先了解到角色和職責問題,也就是說誰是GATT server vs. GATT client,這兩種角色跟之前藍牙主從架構不一樣而是取決於BLE連接成功後,兩個設備間通信的方式。

步驟1 AndroidManifest.xml權限:
需要宣告BLUETOOTH權限,如果需要掃瞄設備或者操作藍牙設置,則還需要BLUETOOTH_ADMIN權限:
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
除了權限外,還需要宣告uses-feature:
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

步驟2 啟動藍牙:
需要確認Android設備是否支持BLE,如果支持BLE,但是藍牙沒打開,則需要打開藍牙。
打開藍牙的步驟:
 1.獲取BluetoothAdapter
 2.判斷是否支持藍牙,並打開藍牙

步驟3 搜索BLE設備:
由於搜索需要盡量減少功耗,在使用時需要注意:
1.當找到對應的設備後,立即停止掃瞄;
2.不要循環搜索設備,為每次搜索設置適合的時間限制。避免設備不在可用範圍的時候持續不停掃瞄,消耗電量。
如果搜索指定UUID,你可以呼叫 BluetoothAdapter.LeScanCallback 方法。

步驟4 連接GATT Server:
連接GATT Server後透過 BluetoothGattCallback 進行連結及讀取資料。

其他步驟細節請參考Android官方文件 Bluetooth Low Energy 說明

以下是本次實作程式碼內容:(程式參考來源: SensorTag BLE App with Code)
MainActivity.java
//-------------------------------程式開始--------------------------------------
package com.example.helloble;

import java.util.UUID;

import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.os.Bundle;
import android.os.Handler;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.TextView;

public class MainActivity extends Activity implements OnClickListener {
public UUID UUID_IRT_SERV = UUID.fromString("f000aa00-0451-4000-b000-000000000000");
public UUID UUID_IRT_DATA = UUID.fromString("f000aa01-0451-4000-b000-000000000000");
public UUID UUID_IRT_CONF = UUID.fromString("f000aa02-0451-4000-b000-000000000000"); // 0: disable,1: enable

public UUID CLIENT_CONFIG_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); //定義手機的UUID

public String DeviceName = "SensorTag";
public BluetoothAdapter BTAdapter;
public BluetoothDevice BTDevice;
public BluetoothGatt BTGatt;

public boolean scanning;
public Handler handler;
public Console console;

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);

console = new Console((TextView) findViewById(R.id.console));
((Button) findViewById(R.id.buttonScan)).setOnClickListener(this);
((Button) findViewById(R.id.buttonClear)).setOnClickListener(this);

//初始化Bluetooth adapter,透過BluetoothManager得到一個參考Bluetooth adapter
BluetoothManager BTManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
BTAdapter = BTManager.getAdapter();

scanning = false;
handler = new Handler();
}

public void onDestroy() {
if (BTGatt != null) {
BTGatt.disconnect();
BTGatt.close();
}
super.onDestroy();
}

public BluetoothGattCallback GattCallback = new BluetoothGattCallback() {
int ssstep = 0;

public void SetupSensorStep(BluetoothGatt gatt) {
BluetoothGattCharacteristic characteristic;
BluetoothGattDescriptor descriptor;
switch (ssstep) {
case 0:
/*
* * Enable IRT Sensor
*/
characteristic = gatt.getService(UUID_IRT_SERV).getCharacteristic(UUID_IRT_CONF);
characteristic.setValue(new byte[] { 0x01 });
gatt.writeCharacteristic(characteristic);
break;
case 1:
/*
* * Setup IRT Sensor
*/
// Enable local notifications
characteristic = gatt.getService(UUID_IRT_SERV).getCharacteristic(UUID_IRT_DATA);
gatt.setCharacteristicNotification(characteristic, true);
// Enabled remote notifications
descriptor = characteristic.getDescriptor(CLIENT_CONFIG_DESCRIPTOR);
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
break;
}
ssstep++;
}

// 偵測GATT client連線或斷線
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
output("Connected to GATT Server");
gatt.discoverServices();
} else {
output("Disconnected from GATT Server");
gatt.disconnect();
gatt.close();
}
}

//發現新的服務
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
output("Discover & Config GATT Services");
ssstep = 0;
SetupSensorStep(gatt);
}

//特徵寫入結果
public void onCharacteristicWrite(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic, int status) {
SetupSensorStep(gatt);
}

//描述寫入結果
public void onDescriptorWrite(BluetoothGatt gatt,
BluetoothGattDescriptor descriptor, int status) {
SetupSensorStep(gatt);
}

//遠端特徵通知結果
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {

//判斷IR Temperature Data,如果有資料則透過TITOOL類別進行資料轉換,傳回值為攝氏單位
if (UUID_IRT_DATA.equals(characteristic.getUuid())) {
double ambient = TITOOL.extractAmbientTemperature(characteristic);
double target = TITOOL.extractTargetTemperature(characteristic, ambient);
//target = target * 1.8 + 32; //轉換華氏
output("@ " + String.format("%.2f", target) + "&deg;C");
}
}
};

//回報由手機設備掃描過程中發現的LE設備
public BluetoothAdapter.LeScanCallback DeviceLeScanCallback = new BluetoothAdapter.LeScanCallback() {
public void onLeScan(final BluetoothDevice device, int rssi,
byte[] scanRecord) {
if (DeviceName.equals(device.getName())) {
if (BTDevice == null) {
BTDevice = device;
BTGatt = BTDevice.connectGatt(getApplicationContext(), false, GattCallback); // 連接GATT
} else {
if (BTDevice.getAddress().equals(device.getAddress())) {
return;
}
}
output("*<small> " + device.getName() + ":" + device.getAddress() + ", rssi:" + rssi + "</small>");
}
}
};

public void BTScan() {
//檢查設備上是否支持藍牙
if (BTAdapter == null) {
output("No Bluetooth Adapter");
return;
}

if (!BTAdapter.isEnabled()) {
BTAdapter.enable();
}

//搜尋BLE藍牙裝置
if (scanning == false) {
handler.postDelayed(new Runnable() {
public void run() {
scanning = false;
BTAdapter.stopLeScan(DeviceLeScanCallback);
output("Stop scanning");
}
}, 2000);

scanning = true;
BTDevice = null;
if (BTGatt != null) {
BTGatt.disconnect();
BTGatt.close();
}
BTGatt = null;

BTAdapter.startLeScan(DeviceLeScanCallback);
output("Start scanning");
}
}

//按鍵事件
public void onClick(View view) {
switch (view.getId()) {
case R.id.buttonScan:
BTScan();
break;
case R.id.buttonClear:
clear();
break;
}
}

//訊息輸出到TextView
public void output(String msg) {
console.output(msg);
}
//清除TextView
public void clear() {
console.clear();
}

//選單(EXIT)
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main, menu);
return true;
}

public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_exit:
if (BTGatt != null) {
BTGatt.disconnect();
BTGatt.close();
}
finish();
System.exit(0);
break;
}
return super.onOptionsItemSelected(item);
}
}

//溫度轉換,轉換公式請參考TI官方SensorTag User Guide
class TITOOL {
public static double extractAmbientTemperature(BluetoothGattCharacteristic c) {
int offset = 2;
return shortUnsignedAtOffset(c, offset) / 128.0;
}

public static double extractTargetTemperature(BluetoothGattCharacteristic c, double ambient) {
Integer twoByteValue = shortSignedAtOffset(c, 0);

double Vobj2 = twoByteValue.doubleValue();
Vobj2 *= 0.00000015625;

double Tdie = ambient + 273.15;

double S0 = 5.593E-14; // Calibration factor
double a1 = 1.75E-3;
double a2 = -1.678E-5;
double b0 = -2.94E-5;
double b1 = -5.7E-7;
double b2 = 4.63E-9;
double c2 = 13.4;
double Tref = 298.15;
double S = S0 * (1 + a1 * (Tdie - Tref) + a2 * Math.pow((Tdie - Tref), 2));
double Vos = b0 + b1 * (Tdie - Tref) + b2 * Math.pow((Tdie - Tref), 2);
double fObj = (Vobj2 - Vos) + c2 * Math.pow((Vobj2 - Vos), 2);
double tObj = Math.pow(Math.pow(Tdie, 4) + (fObj / S), .25);

return tObj - 273.15;
}

public static Integer shortSignedAtOffset(BluetoothGattCharacteristic c, int offset) {
Integer lowerByte = c.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, offset);
Integer upperByte = c.getIntValue(BluetoothGattCharacteristic.FORMAT_SINT8, offset + 1);

return (upperByte << 8) + lowerByte;
}

public static Integer shortUnsignedAtOffset(BluetoothGattCharacteristic c, int offset) {
Integer lowerByte = c.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, offset);
Integer upperByte = c.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, offset + 1);

return (upperByte << 8) + lowerByte;
}
}
//-------------------------------程式結束--------------------------------------



顯示 Console.java
//-------------------------------程式開始--------------------------------------
package com.example.helloble;

import android.app.Activity;
import android.text.Html;
import android.text.Layout;
import android.text.method.ScrollingMovementMethod;
import android.widget.TextView;

public class Console {
public Activity activity;
public TextView textview;

public Console(TextView paramTextView) {
if (paramTextView != null) {
this.textview = paramTextView;
paramTextView.setMovementMethod(new ScrollingMovementMethod());
this.activity = ((Activity) paramTextView.getContext());
}
}

public void clear() {
this.textview.setText("");
}

public void output(String paramString) {
if (this.textview == null) {
return;
}
this.activity.runOnUiThread(new TextViewOutput(this.textview, paramString));
}
}

class TextViewOutput implements Runnable {
public TextView console;
public String message;

public TextViewOutput(TextView paramTextView, String paramString) {
this.console = paramTextView;
this.message = paramString;
}

public void run() {
this.console.append(Html.fromHtml(this.message + "<br>"));
Layout localLayout = this.console.getLayout();
if (localLayout != null) {
int i = localLayout.getLineBottom(this.console.getLineCount() - 1)
- this.console.getScrollY() - this.console.getHeight();
if (i > 0) {
this.console.scrollBy(0, i);
}
} else {
return;
}
this.console.scrollTo(0, 0);
}
}
//-------------------------------程式結束--------------------------------------

佈局 activity_main.xml
//-------------------------------程式開始--------------------------------------
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical" >

    <ScrollView
        android:id="@+id/uart_scrollview"
        android:layout_width="fill_parent"
        android:layout_height="0dip"
        android:layout_alignParentRight="true"
        android:layout_marginRight="5px"
        android:layout_marginTop="5px"
        android:layout_weight="1"
        android:background="#ffffffff" >

        <TextView
            android:id="@id/console"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:text=""
            android:textColor="#ff000000"
            android:textSize="16.0sp" >
        </TextView>
    </ScrollView>

    <LinearLayout
        android:id="@id/ButtonGroup"
        android:layout_width="fill_parent"
        android:layout_height="0dip"
        android:layout_weight="0.1"
        android:orientation="horizontal" >

        <Button
            android:id="@id/buttonScan"
            android:layout_width="0.0dip"
            android:layout_height="fill_parent"
            android:layout_weight="0.5"
            android:text="Scan"
            android:textSize="20.0sp" />

        <Button
            android:id="@id/buttonClear"
            android:layout_width="0.0dip"
            android:layout_height="fill_parent"
            android:layout_weight="0.5"
            android:text="Clear"
            android:textSize="20.0sp" />
    </LinearLayout>

</LinearLayout>
//-------------------------------程式結束--------------------------------------

在Value裡面要定義幾個id值:
//-------------------------------程式開始--------------------------------------
<?xml version="1.0" encoding="utf-8"?>
<resources>
....
    <item type="id" name="console">false</item>
    <item type="id" name="ButtonGroup">false</item>
    <item type="id" name="buttonScan">false</item>
    <item type="id" name="buttonClear">false</item>
    <item type="id" name="action_emailcode">false</item>
    <item type="id" name="action_exit">false</item>
</resources>
//-------------------------------程式結束--------------------------------------
執行結果:




後記:
1. 在 GATT Specifications 中的 Profiles 其實沒有定義  Serial Port Profile (SPP),這是因為 SPP 無法發揮 BLE 的優點,所以拍買網上標榜BLE SPP大都是自家規格並非標準。
詳細可參考 TI CC254x: BLE SPP (Serial Port Profile) (1) 這篇文章有相關說明。

2.撰寫BLE裝置需要取的相關原廠或是開發商會提供 attribute table (屬性表),從屬性表中找出相關的服務與特徵,假如要讀取的是GATT Specifications標準的Porfile例如血壓、心跳之類的,可以到官網查詢相關鏈結如下:
GATT Specifications > Profiles
GATT Specifications > Services
GATT Specifications > Characteristics
GATT Specifications > Descriptors

3.Bluetooth 網站有列出目前支援 Bluetooth Smart (BLE) 的裝置, 所以也可以直接到這邊查:
http://www.bluetooth.com/Pages/Bluetooth-Smart-Devices-List.aspx


參考:
1.Bluetooth® Core Specification
https://www.bluetooth.org/en-us/specification/adopted-specifications

2.
Android BLE Scanner 實作 (1) - getting started
http://thinkingiot.blogspot.tw/2015/02/android-ble-scanner-1-getting-started.html
Android BLE Scanner 實作 (2) - 如何 Parse BLE broadcasting/advertisement 封包
http://thinkingiot.blogspot.tw/2015/02/parse-ble-broadcastingadvertisement.html
Android BLE Scanner 實作 (3) - Characteristic Read/Write/Indication/Notification
http://thinkingiot.blogspot.tw/2015/02/android-ble-scanner-3-characteristic.html

3.
BluetoothAdapter.LeScanCallback
BluetoothGattCallback

4.Getting Started with Bluetooth Low Energy
https://www.safaribooksonline.com/library/view/getting-started-with/9781491900550/

5.Android Bluetooth Low Energy
https://developer.android.com/guide/topics/connectivity/bluetooth-le.html

6.SensorTag BLE App with Code
https://play.google.com/store/apps/details?id=com.togosoft.sensortag2&hl=zh-TW

7.GitHub: Adafruit_Android_BLE_UART
https://github.com/adafruit/Adafruit_Android_BLE_UART

15 則留言:

  1. 不好意思想請問一下,因為暑假再研究Android Studio 寫app的程式,那現在碰到的困難是關於藍芽4.0也就是低功耗藍芽,程式要如何寫可以傳輸資料,例如想控制arduino上的led開關,我用手機連上arduino上的藍芽模組,那現在卡在不知如何寫程式可以傳送資料到arduino上的藍芽進而控制led亮或暗!
    想請教一下謝謝!

    回覆刪除
    回覆
    1. BLE 來控制LED,由於我手邊沒有BLE SPP (Serial Port Profile) 的硬體,相關觀念你可以參考下列網址,或許可以解決你的問題:
      http://thinkingiot.blogspot.tw/2015/04/ti-cc254x-ble-spp-serial-port-profile-1.html

      刪除
  2. 您好:
    我想請問說 是否一定要先設定好UUID才能夠進行連結呢?
    我在使用nRF BLE scanner時可以在建立連結後取得BLE server的UUID
    接著就可以進行資料收發的動作
    請問該怎麼實作 不先預設UUID還是可以接收資料呢?
    謝謝

    回覆刪除
  3. 您好:我是剛學習android studio的新手..
    想請教一下!
    請問Console.java是在MainActivity.java那邊創建一個java class嗎?
    另外Value裡面定義的id值是要放在strings.xml還是style.xml呢?

    回覆刪除
    回覆
    1. src 下放你的.java
      string.xml放id
      同學 你要從基礎學不要從最初階的就開始問人

      刪除
  4. 作者已經移除這則留言。

    回覆刪除
  5. 不好意思,請問有全部的CODE可以看嗎?

    回覆刪除
    回覆
    1. 文章內容已經是完整程式碼了,如果資料還不夠您可以到github上用 "SensorTag Android "這兩個字搜尋將會有一堆程式碼。

      刪除
  6. 作者已經移除這則留言。

    回覆刪除
  7. 不好意思,因為剛接觸藍牙相關的手機開發,使用這個程式進行測試,執行後出現只出現開始掃描與與結束掃描,其餘的資訊都沒有,想問一下這是什麼原因? 謝謝

    回覆刪除
  8. 想問如何同時接取溫度和濕度的 資料

    回覆刪除
    回覆
    1. 想同時看到2種資料 已得到2個不同資訊

      刪除
    2. 這要看感知器提供的通訊協定而定

      刪除