Swift3でのBLEアプリ開発-その2-

Swift3でBLEアプリを作ってみる

前回 https://blog.reinforce-lab.com/2016/12/01/ble-with-swit3-1/ は、Swift3でBLEアプリを作るときによく使う、バイナリ配列と値型との変換処理を書きました。今回はCoreBluetoothフレームワークとその使い方をまとめます。

ソースコードは https://github.com/ubi-naist/SenStick/tree/master/SenStickSDK にあります。

クラス構成

SDKの役割は、複数のBLEデバイスのセンサーデータの取得、そしてその蓄積データの読み出し機能の提供です。そこで、複数のBLEデバイスの発見と接続処理を SenStickDeviceManagerクラスに、BLEデバイスそれぞれを SenStickDevice で表しています。

これはCoreBluetoothの2つのクラスのラッパーになっています。

  • CBCentralManager - SenStickDeviceManager
  • CBPeripheral - SenStickDevice

SenStickDeviceManager, DispatchQueueの割当とスキャン処理

SenStickDeviceManagerクラスは:

  • Key-Value Observation(KVO)準拠にする
  • 通信処理はディスパッチキューを使う
  • シングルトンにする になっています。
open class SenStickDeviceManager : NSObject, CBCentralManagerDelegate {
let queue: DispatchQueue
var manager: CBCentralManager?

// Properties, KVO
dynamic open fileprivate(set) var devices:[SenStickDevice] = []
dynamic open fileprivate(set) var isScanning: Bool = false

// Initializer, Singleton design pattern.
open static let sharedInstance: SenStickDeviceManager = SenStickDeviceManager()

fileprivate override init() {
 queue = DispatchQueue(label: "senstick.ble-queue", attributes: nil)
 super.init()
 manager = CBCentralManager.init(delegate: self, queue: queue)
}

スキャン中かどうかなどのステートは、読み出し専用プロパティで外部に見せています。SwiftのクラスのプロパティをKVO準拠とするには:

  1. NSObjectを継承すること
  2. 宣言に"dynamic"をつけること

が必要です。

通信処理はディスパッチキューを使います。CBCentralManagerはキューにnilを指定すれば、メインスレッドで処理をします。心拍計のように、さほどデータのやり取りがないデバイスが相手であれば、メインスレッドで処理をしてもよいのですが、今回のデバイスは、ある程度量のあるログ・データを読み出すため、メインスレッドで処理をしては画面表示の処理を止めてしまいます。

変数queueはletで宣言しているので、継承しているNSObject.init()を呼び出す前に、初期化する必要があります。通信の処理は受信した順番どおりに処理しなければならないので、シリアルキューを使います。

DISPATCH_QUEUE_SERIAL (or NULL) to create a serial queue or specify DISPATCH_QUEUE_CONCURRENT to create a concurrent queue.

変数managerは、delegateにselfを渡します。selfを参照するにはイニシャライザの処理が終わっていなければなりません。letで宣言すると、その変数を初期化するのに、それがselfを参照するから、その変数が初期化できなくなります。しかたないのでvar managerと宣言しています。

CBCentralManagerのインスタンスは、アプリケーションで1つだけにすることが推奨されています。そこでCBCentralManagerをもつSenStickDeviceManagerをシングルトンにしています。

実際には、アプリがCBCentralManagerを複数持っていても動作するのですが、スキャンやデバイスの発見処理を考えれば、たしかに1つだけにまとめておいたほうが後々トラブルにならずにすみそうです。

Swiftのシングルとnは、letでインスタンスを宣言して、そこにインスタンスを作って返す無記名関数を使って、インスタンスを入れます。

// 1秒毎にコールバックします。0になれば終了です。
  open func scan(_ duration:TimeInterval = 5.0, callback:((_ remaining: TimeInterval) -> Void)?)
  {
      // デバイスリストをクリアする
      DispatchQueue.main.async(execute: {
          self.devices = []
      })

      // スキャン中、もしくはBTの電源がオフであれば、直ちに終了。
      if manager!.isScanning || manager!.state != .poweredOn {
          callback?(0)
          return
      }

      // スキャン時間は1秒以上、30秒以下に制約
      let scanDuration = max(1, min(30, duration))
      scanCallback = callback

      // 接続済のペリフェラルを取得する
      for peripheral in (manager!.retrieveConnectedPeripherals(withServices: [SenStickUUIDs.advertisingServiceUUID])) {
          addPeripheral(peripheral, name:nil)
      }

      // スキャンを開始する。
      manager!.scanForPeripherals(withServices: [SenStickUUIDs.advertisingServiceUUID], options: nil)
      isScanning = true

      var remaining = scanDuration
      scanTimer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags(rawValue: UInt(0)), queue: DispatchQueue.main)
      scanTimer?.scheduleRepeating(deadline: DispatchTime.now(), interval: 1.0) // 1秒ごとのタイマー
      scanTimer?.setEventHandler {
          // 時間を-1秒。
          remaining = max(0, remaining - 1)
          if remaining <= 0 {
              self.cancelScan()
          }
          // 継続ならばシグナリング
          self.scanCallback?(remaining)
      }
      scanTimer!.resume()
  }

scanメソッドは、BTの電源が入っていれば、すでに接続中しているデバイスのリストを取得してから、スキャンを開始します。受信回路を動かすスキャンは電力を消費し続けるので、タイマーを使い5秒で停止させています。

SenStickDevice, サービスの発見と受信処理

サービスの発見、そしてキャラクタリスティクスの発見の処理は、CoreBluetoothで手間のかかるコードです。発見したサービスごとに、そのサービスで発見すべきキャラクタリスティクスを指定するコードは、そのまま書くと読みにくくなるので、サービスおよびキャラクタリスティクスの配列と、その処理に分けて、見た目を良くしています。

サービスおよびキャラクタリスティクスのUUIDはSenStickUUIDsの構造体にまとめています。

public static let ControlServiceUUID:CBUUID        = {return SenStickUUIDs.createSenstickUUID(0x2000)}()
public static let StatusCharUUID:CBUUID            = {return SenStickUUIDs.createSenstickUUID(0x7000)}()

public static let SenStickServiceUUIDs: [CBUUID: [CBUUID]] = [

    DeviceInformationServiceUUID : [ManufacturerNameStringCharUUID, HardwareRevisionStringCharUUID, FirmwareRevisionStringCharUUID, SerialNumberStringCharUUID],

    BatteryServiceUUID: [BatteryLevelCharUUID],

    ControlServiceUUID   : [StatusCharUUID, AvailableLogCountCharUUID, StorageStatusCharUUID, DateTimeCharUUID, AbstractCharUUID, DeviceNameCharUUID],

カスタムサービスのUUIDを、CBUUIDのイニシャライザそのままを呼び出して作ると、128ビットのUUIDを書かねばなりません。カスタムサービスそしてキャラクタリスティクスのUUIDは、自分が勝手に決めた128ビットのUUIDの、そのうち16ビットだけを変化させるように仕様を決めています。

そこで、その勝手なカスタムUUIDを作るメソッドを作り、それを無記名関数で呼び出してインスタンスにして、staticなletな変数に実行開始時に割り当てています。こうすれば、カスタムUUIDでも、16ビットの本当に指定する部分だけをコードに書けば済みます。

また、サービスとキャラクタリスティクスの構成は、サービスのUUIDをキーに、キャラクタリスティクスのUUIDの配列を値に持つハッシュで表現しています。

これを使って、SenStickDeviceのサービス発見では、UUIDをキーにして発見すべきキャラクタリスティクスのUUIDの配列を取り出し、キャラクタリスティクス発見の処理を進める、としています。

複雑なインスタンスでも、そのインスタンスを返す無記名関数を利用すれば、static letで宣言できるのでよいのですが、ちょっとテクニックに走り過ぎというか、無記名関数が入るのがあまりきれいでもないようにも思えるので、普通に構造体としてイニシャライザで初期化すればよかったかもしれません。

サービスそれぞれのクラスのインスタンス

BLEのサービスを発見するたび、SDKでの機能を表すクラスのインスタンスを作ります。BLEのサービスはハードウェアとしての機能単位で、アプリケーションからみた機能単位とBLEのサービスとが同じとは限らないのですが、今回は一致しているのでBLEのサービスそれぞれにクラスを割り当てています。

SenStickDeviceでサービスに対応するクラスのインスタンスを作るのですが、どのような条件ならインスタンスができるかをSenStickDevice側で処理させると、コードがめちゃくちゃ読みにくくなります。

そこで、例えばデバイス情報サービスを表すDeviceInformationSeviceクラスのイニシャライザ:

// イニシャライザ
  required public init?(device:SenStickDevice)
  {
      self.device = device

      guard let service = device.findService(SenStickUUIDs.DeviceInformationServiceUUID) else { return nil }

のように、サービスとそのキャラクタリスティクスをすべて呼び出したならば、インスタンスを作って返すようにしています。必要なキャラクタリスティクスが発見できていないなど、それぞれのサービスごとの都合は、guard文でインスタンスを作る条件を満たしていなければnullを返すことで、表現しています。

センサーログデータの読み出し処理

ログデータの読み出しでは、コネクション・インターバル20ミリ秒ごとに6パケット、つまり6回の受信イベントが起きます。

// 過度な呼び出しにならないように、メインスレッドでの処理が終わったら次の処理を入れるようにする。
var flag:Bool // obj_sync_enter()/obj_sync_exit()を使い排他処理したフラグ

if(self.flag == false) {
    self.flag = true
    DispatchQueue.main.async(execute: {
        self.delegate?.didUpdateLogData(self)
        self.flag = false
    })

値を読み出すたびにUIスレッドにそれを通知するのですが、受信するたびに素直にそれをキューに入れるとUIが遅くなるので、かんたんにフラグを立てて、UIスレッドの表示処理が終わったら次の通知を入れるようにしています。(セマフォを使えばいいだけなのに、なぜか自分でフラグを実装しています。)

ジェネリックを使うとKVOが使えなくなって、ちょっと困った

センサそれぞれのデータ読み出しサービスは、読み出し手順はおなじだけれども、範囲指定の設定値とセンサデータのバイト配列表現だけが違うものです。

手順が同じでデータの表現だけが違うものとくればジェネリックを使う場面だと思い、こんな感じで書いています。

open class SenStickSensorService<T: SensorDataPackableType, S: RawRepresentable> where S.RawValue == UInt16, T.RangeType == S

open class AccelerationSensorService: SenStickSensorService<CMAcceleration, AccelerationRange>

加速度のセンサデータは、独自に定義してもいいのですが、CoreMotionにあるデータ構造体を拝借して、使っています。センサの測定派に設定値をenumでUInt16で定義しています。センサデータはバイナリ配列から戻せるようにプロトコルを定義して、CoreMotionから拝借している構造体を拡張しています。

// 加速度センサーの範囲設定値。列挙側の値は、BLEでの設定値に合わせている。
public enum AccelerationRange : UInt16, CustomStringConvertible
{
    case accelerationRange2G   = 0x00 // +- 2g
    case accelerationRange4G   = 0x01 // +- 4g
    case accelerationRange8G   = 0x02 // +- 8g
    case accelerationRange16G  = 0x03 // +- 16g

    public var description : String
    {
        switch self {
        case .accelerationRange2G: return "2G"
        case .accelerationRange4G: return "4G"
        case .accelerationRange8G: return "8G"
        case .accelerationRange16G:return "16G"
        }
    }
}

// センサーからの生データ。16ビット 符号付き数値。フルスケールは設定レンジ値による。2, 4, 8, 16G。
struct AccelerationRawData
{
    var xRawValue : Int16
    var yRawValue : Int16
    var zRawValue : Int16

    init(xRawValue:Int16, yRawValue:Int16, zRawValue:Int16)
    {
        self.xRawValue = xRawValue
        self.yRawValue = yRawValue
        self.zRawValue = zRawValue
    }

    // 物理センサーの1GあたりのLBSの値
    static func getLSBperG(_ range: AccelerationRange) -> Double
    {
        switch range {
        case .accelerationRange2G: return 16384
        case .accelerationRange4G: return 8192
        case .accelerationRange8G: return 4096
        case .accelerationRange16G:return 2048
        }
    }

    static func unpack(_ data: [Byte]) -> AccelerationRawData
    {
        let x = Int16.unpack(data[0..<2])
        let y = Int16.unpack(data[2..<4])
        let z = Int16.unpack(data[4..<6])

        return AccelerationRawData(xRawValue: x!, yRawValue: y!, zRawValue: z!)
    }
}

extension CMAcceleration : SensorDataPackableType
{
    public typealias RangeType = AccelerationRange

    public static func unpack(_ range:AccelerationRange, value: [UInt8]) -> CMAcceleration?
    {
        guard value.count >= 6 else {
            return nil
        }

        let rawData  = AccelerationRawData.unpack(value)
        let lsbPerG  = AccelerationRawData.getLSBperG(range)

        // FIXME 右手系/左手系などの座標変換など確認すること。

        return CMAcceleration(x: Double(rawData.xRawValue) / Double(lsbPerG), y: Double(rawData.yRawValue) / Double(lsbPerG), z: Double(rawData.zRawValue) / Double(lsbPerG))
    }
}

このやり方はコードがすっきりするのですが、ジェネリックを使うクラスはNSObjectを継承できないので、KVOにできません。ですから、このクラスからなにか値の変更を通知させたいなら、プロトコルでデリゲートを宣言して、デリゲート経由で通知するほかなくなります。

プロパティのset文でデリゲートを呼び出せばいいだけなので、あっちこっちで値を変更するたび忘れずdelegate呼び出しをその都度書くような、ミスしやすい書き方は避けられるのですが、なんとなくKVOに慣れすぎていて、これでいいのかなと中途半端な感じがします。

まとめ

Swift3でBLEデバイスのSDKを作ってみると、ジェネリックや構造体の拡張を使うと、驚くほどすっきりしたコードが書けると思いました。その一方で、この書き方は初めてコードを読む人、あるいはコードのメンテを任された人がみて、ぱっと見で理解しやすいのだろうか? と疑問もあります。

あまり凝りすぎても仕方なく、また無闇矢鱈に拡張を使いすぎて、ファイルのあちこちに処理が分散して、ぱっと見てわからなくなったりしないように注意しないとと思いました。