「Applibot Advent Calendar 2020」 13日目の記事になります。
前日は@mrpcさんの配信・中継で「音質」にこだわりたいときという記事でした!
はじめに
あなたの心臓は動いていますか?
心臓の動きを見ることで自身の状態を知ることができます。
学生時代に緊張している時の心拍数を計測したくてMacの常駐アプリを作りました。
それを使って最終面接中に心拍数を見せて緊張してることをアピールして入社したのが今の会社です!
今回はBLEの概要とCore Bluetoothフレームワークを使って心拍センサーと接続して心拍数を取得する方法を説明します。
BLE(Bluetooth Low Energy)概要
- セントラル・・・・スキャン・接続を行う側
- 今回のアプリではMac
- ペリフェラル・・・スキャン・接続される側
- 今回のアプリでは心拍センサー
ペリフェラルは接続待ちの間、アドバタイズと呼ばれるブロードキャスト型の通信で自身のデータを発信します。
セントラルはスキャンすることでアドバタイズを受信して周囲のペリフェラルを見つけます。
- GATT(汎用属性プロファイル)
- データの送受信や構造について定義したもの
- サービス
- GATTに定義されていて、複数のキャラクタリスティックをグループ化したもの
- キャラクタリスティック
- データ構造や送受信方法が定義されている
- Read/Write/Notifyの属性を持っていて、属性にない操作はできない
セントラルは目的のペリフェラルと接続成功するとGATT通信を用いて以下の手順でデータの読み書きを行います。
- GATTサーバーに接続する
- サービスを検索する
- キャラクタリスティックを検索する
- 目的のキャラクタリスティックに対してデータの読み書きをする
Heart Rate Profile
Bluetooth SIGで定義されている心拍計のためのプロファイルで心拍計の機能に関するHeart Rate Service
とデバイスの情報に関するDevice Information Service
の2つのサービスを持っています。
Heart Rate Service
短縮UUID : 180D
心拍数を測定するHeart Rate Measurement
やセンサを身体のどこにつけるかを示すBody Sensor Location
、心拍計を操作するためのHeart Rate Control Point
などのキャラクタリスティックを持っています。
Heart Rate Measurement Characteristic
短縮UUID : 2A37
Notify属性を持っていて、センサーデータが更新された時に通知されます。
データ仕様は以下のようになっています。
- フラグ(8bit)
- 心拍数フォーマット(1bit)
- 0: uint8
- 1: uint16
- センサー接触状態(2bit)
- 0: 機能をサポートしていない
- 1: 機能をサポートしていない
- 2: 機能はサポートしているが、接触を検知できない
- 3: 機能をサポートしていて、接触を検知している
- 消費電力状態(1bit)
- 0: 値なし
- 1: 値あり
- RRI(1bit)
- 0: 値なし
- 1: ひとつ以上のRRI値
- 心拍数フォーマット(1bit)
- 心拍数(uint8)
- 心拍数(uint16)
- 消費電力(uint16)
- RRI(uint16)
心拍数の取得
AppleのCore Bluetoothフレームワークを使って心拍センサから心拍数データを取得する手順
CBCentralManager
を初期化します
self
はCBCentralManagerDelegate
を継承しています
self.centralManager = CBCentralManager(delegate: self, queue: nil)
CBCentralManagerDelegate
によってBluetoothの状態を検知できるので接続可能な場合にscanForPeripherals
を呼んでアドバタイズ中のペリフェラルをスキャンします。
引数のwithServicesに目的のサービスのUUIDを渡すことでそのサービスを持っているペリフェラルを探すことができます。
今回ははHeart Rate ServiceのUUIDを渡します。
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
// スキャン開始
self.centralManager.scanForPeripherals(withServices: [CBUUID(string: "180D")], options: nil)
default:
break
}
}
ペリフェラルを見つけると以下のメソッドが呼ばれるのでペリフェラルをメニューに追加します。
メニューに追加されたペリフェラルから接続したいものを押下するとconnectAction
が呼ばれるようにしています。
// Peripheralが見つかった
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
guard let name = peripheral.name else {
return
}
// リストにまだ存在しないペリフェラルをメニューに追加
if !self.peripherals.keys.contains(name) {
let item = NSMenuItem()
item.title = name
item.action = #selector(connectAction)
item.state = .off // チェックマークをOFF
self.menu.insertItem(item, at: self.menu.items.count)
}
self.peripherals[name] = peripheral
}
スキャンは明示的に止める必要があるので
connectAction
では最初にスキャンを止めて、接続中のペリフェラルがあれば切断します。
CBCentralManager
のconnect
メソッドに接続したいペリフェラルを引数に渡すことで接続要求をします。
@IBAction func connectAction(sender: NSButton) {
self.centralManager.stopScan()
if self.peripheral != nil {
self.centralManager.cancelPeripheralConnection(self.peripheral)
}
self.peripheral = self.peripherals[sender.title]!
// メニュー一覧のチェックを外す
self.menu.items.forEach{ $0.state = .off }
// ペリフェラルと接続する
self.centralManager.connect(self.peripheral, options: nil)
// 接続中のペリフェラルにチェック
sender.state = .on
}
ペリフェラルと接続が完了すると以下のメソッドが呼ばれるので接続完了したペリフェラルのデリゲートにCBPeripheralDelegate
を継承したself
を代入します。
ペリフェラルのdiscoverServices
メソッドにサービスのUUIDを渡してサービスを探します。
// ペリフェラルと接続完了
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
self.peripheral.delegate = self
// 目的のServiceを探す
self.peripheral.discoverServices([CBUUID(string: "180D")])
}
サービスが見つかると以下のメソッドが呼ばれるので今度はキャラクタリスティックを探します。
ここではdiscoverCharacteristics
の引数にはHeart Rate MeasurementのUUIDを渡しています。
// Serviceが見つかった
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
// 目的のCharacteristicsを探す
peripheral.discoverCharacteristics([CBUUID(string: "2A37")], for: peripheral.services![0])
}
キャラクタリスティックを見つけたら、キャラクタリスティックが持っているRead/Write/Notify属性によって適切なメソッドを呼んでください。
Heart Rate MeasurementはNotify属性を持っているのでここではsetNotifyValue
を呼んで心拍センサーの値を購読します。
// Characteristicが見つかった
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
// Notifyで値を購読する
peripheral.setNotifyValue(true, for: service.characteristics![0])
}
心拍センサーからデータが通知されると以下のメソッドが呼ばれるのでHeart Rate Measurement Characteristicを参考にデータをパースして心拍数などを取得します。
// 購読中の値が更新された
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
self.updateWithData(data: characteristic.value! as NSData)
}
func updateWithData(data: NSData) {
// データ数
let count = data.length / MemoryLayout<UInt8>.size
// データ配列
var dataArray = [UInt8](repeating: 0, count: count)
// データをUInt8配列にコピー
data.getBytes(&dataArray, length: count * MemoryLayout<UInt8>.size)
var offset = 0
// Flags取得
let flags = dataArray[offset]
offset += 1
var heartRate: UInt16 = 0
var RRI: [UInt16] = []
// 心拍数がUInt8の時
if flags & 0b00001 == 0 {
heartRate = UInt16(dataArray[offset])
print("心拍数:",heartRate)
offset += 1
} else {// 心拍数がUInt16の時
// UInt8を連結してUInt16に変換
let bytes:[UInt8] = [dataArray[offset], dataArray[offset+1]]
let data = NSData(bytes: bytes, length: 2)
data.getBytes(&heartRate, length: 2)
print("心拍数:",heartRate)
offset += 2
}
// 電力消費フィールドがある時
if (flags&0b01000)>>3 == 1 {
// 電力消費フィールド分offsetを進める
offset += 1
}
// センサーが接触状態かつRRI値がある場合
if ((flags&0b00110)>>1 == 3) && ((flags&0b10000)>>4 == 1) {
// offsetからデータサイズまで2ずつループ(RRIはUInt16のため)
for i in stride(from: offset, to: dataArray.count, by: 2) {
// UInt8を連結してUInt16に変換
let bytes:[UInt8] = Array(dataArray[offset..<i+2])
let uint16size = MemoryLayout<UInt16>.size
let data = NSData(bytes: bytes, length: uint16size)
var u16:UInt16 = 0
data.getBytes(&u16, length: uint16size)
RRI.append(u16)
offset += 2
}
}
for rri in RRI {
let rriString = String(Double(rri)/1024*1000)
print("RRI:",rriString)
}
}
おわりに
Core Bluetoothを使うことで簡単に心拍センサーと接続することができました。
心拍センサはRRIと呼ばれる心電図波形のR波とR波の間隔も取得することができます。
RRIを解析することで交感神経と副交感神経のどちらが優位かなどもわかるので興味があれば調べてください。
心拍センサー以外でもBLE接続可能なものであれば簡単に使うことができるので、是非いろんなセンサーと接続してみてください!
「Applibot Advent Calendar 2020」 13日目の記事でした。
明日は@hatayama_abさんです。