高速藍牙主攻資料交換與傳輸;傳統藍牙則以資訊溝通、裝置連線為重點;藍牙低功耗顧名思義,以不需佔用太多頻寬的裝置連線為主。
本篇文章來探討一下如何透過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) + "°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
不好意思想請問一下,因為暑假再研究Android Studio 寫app的程式,那現在碰到的困難是關於藍芽4.0也就是低功耗藍芽,程式要如何寫可以傳輸資料,例如想控制arduino上的led開關,我用手機連上arduino上的藍芽模組,那現在卡在不知如何寫程式可以傳送資料到arduino上的藍芽進而控制led亮或暗!
回覆刪除想請教一下謝謝!
BLE 來控制LED,由於我手邊沒有BLE SPP (Serial Port Profile) 的硬體,相關觀念你可以參考下列網址,或許可以解決你的問題:
刪除http://thinkingiot.blogspot.tw/2015/04/ti-cc254x-ble-spp-serial-port-profile-1.html
您好:
回覆刪除我想請問說 是否一定要先設定好UUID才能夠進行連結呢?
我在使用nRF BLE scanner時可以在建立連結後取得BLE server的UUID
接著就可以進行資料收發的動作
請問該怎麼實作 不先預設UUID還是可以接收資料呢?
謝謝
您好:我是剛學習android studio的新手..
回覆刪除想請教一下!
請問Console.java是在MainActivity.java那邊創建一個java class嗎?
另外Value裡面定義的id值是要放在strings.xml還是style.xml呢?
src 下放你的.java
刪除string.xml放id
同學 你要從基礎學不要從最初階的就開始問人
作者已經移除這則留言。
回覆刪除不好意思,請問有全部的CODE可以看嗎?
回覆刪除文章內容已經是完整程式碼了,如果資料還不夠您可以到github上用 "SensorTag Android "這兩個字搜尋將會有一堆程式碼。
刪除謝謝
刪除作者已經移除這則留言。
回覆刪除不好意思,因為剛接觸藍牙相關的手機開發,使用這個程式進行測試,執行後出現只出現開始掃描與與結束掃描,其餘的資訊都沒有,想問一下這是什麼原因? 謝謝
回覆刪除想問如何同時接取溫度和濕度的 資料
回覆刪除為何一定要同時?
刪除想同時看到2種資料 已得到2個不同資訊
刪除這要看感知器提供的通訊協定而定
刪除