cordova-plugin-bluetoothle を使って iOS 同士で Bluetooth 通信する Cordova アプリを作る : 3 ペリフェラル編 (後編)

前回の続き。

前回までで、ペリフェラル端末として動作するのに必要な API を Promise 化して用意するところまでができた。

今回は各 API にどのようなオプションを渡して動かせば良いのか紹介し、initializePeripheral の特殊な動きを確認していきたい。

まずは処理を並べてみる

まずはコンポーネント側の「ペリフェラル通信開始」ボタンを押下したときの処理として、前回作ったサービスクラスのメソッドを並べるだけ並べてみようと思う。ペリフェラル端末はこのように通信準備を行い、通信を止めていく、ということを確認しよう。

@Component({ /* 省略 */ })
export class PeripheralComponent {
  // プロパティの類、およびサービスクラスの DI は省略
  
  /** 「ペリフェラル通信開始」ボタン押下時の処理 */
  execPeripheral() {
    // メッセージ更新 (基本的には console.log と同等と思ってください)
    this.updateMessage('ペリフェラル通信開始');
    
    // 一定時間セントラル端末からの要求がなければ終了処理を実行するためのタイマー
    let waitTimer;
    
    this.peripheralService.initializePeripheral((result) => {
      // TODO : initializePeripheral のコールバック関数は後で実装
    })
      .then(() => {
        this.updateMessage('ペリフェラル初期化完了・サービス追加開始');
        return this.peripheralService.addService( /* TODO : オプション */ );
      })
      .then(() => {
        this.updateMessage('サービス追加完了・アドバタイジング開始');
        return this.peripheralService.startAdvertising( /* TODO : オプション */ );
      })
      .then(() => {
        this.updateMessage('アドバタイジング開始・セントラル端末の通信待機中…');
        
        // 10秒間セントラル端末からの応答がなければ終了処理を呼び出すためタイマーを設置する
        // TODO : タイマーの解除処理は別途実装する
        waitTimer - setTimeout(() => {
          this.updateMessage('10秒間応答がなかったため中断します');
          this.destroyPeripheral();
        }, 10 * 1000);
      })
      .catch((error) => {
        // どこかの処理で失敗したらエラーメッセージを表示
        this.updateMessage(`ペリフェラル通信開始処理に失敗しました : ${error}`);
      });
  }
  
  /** 進捗を示すメッセージを更新し描画を強制更新する */
  private updateMessage(message: string) {
    this.message = message;
    this.changeDetectorRef.detectChanges();
  }
  
  /** ペリフェラル端末の終了処理 */
  private destroyPeripheral() {
    this.updateMessage('終了処理開始 : アドバタイジング終了');
    this.peripheralService.stopAdvertising()
      .then(() => {
        this.updateMessage('アドバタイジング終了完了・サービス終了開始');
        return this.peripheralService.removeAllServices();
      })
      .then(() => {
        // 全て正常終了
        this.updateMessage('サービス終了完了・ペリフェラル通信の終了処理が完了');
      })
      .catch((error) => {
        // どこかの処理で失敗したらエラーメッセージを表示
        this.updateMessage(`ペリフェラル通信終了処理に失敗しました : ${error}`);
      });
  }

今回のサンプルは、複雑な要求・応答のやりとりの中でのエラーハンドリングをしないようにするため、「接続して通信したら切断する」という一連の動作をほぼ自動で行うようにしている。

サービスクラスに用意したメソッドのうち、respond だけ登場していない。また、waitTimer というタイマー変数のキャンセル処理もない。これはこのあと initializePeripheral のコールバックの中で実装する。

updateMessage() という関数では、this.message を更新しながら、this.changeDetectorRef.detectChanges() を呼んでいる。これは何かというと、Angular の ChangeDetectorRef クラスのことで、画面を強制的に再描画させるための API だ。前回も触れたように、initializePeripheral のコールバックが発火するタイミングが通常のイベントの範囲とは異なる特殊なタイミングになるので、Angular がイベントとして検知できない瞬間があるのだ。そうすると this.message の値を変えても画面が再描画されず、メッセージが更新されたことが分からないのである。これを回避するために detectChanges() を呼んでいるワケだ。以前以下の記事でも紹介しているので、こちらも参照してほしい。

initializePeripheral のコールバック関数を実装する

いよいよ initializePeripheral のコールバック関数を実装する。この実装を見てやっと、「特殊なタイミングで発火する」の意味が分かっていただけるかと思う。

// execPeripheral() 内

// 一定時間セントラル端末からの要求がなければ終了処理を実行するためのタイマー
let waitTimer;

this.peripheralService.initializePeripheral((result) => {
  // ステータスからコールバックの発火内容を分ける
  
  if(result.status === 'writeRequested') {
    this.updateMessage('write 要求を受信しました');
    
    // 受信したテキスト (result.value) をデコードしてテキストボックスに出力する
    // TODO : BluetoothService#decodeText() は後で実装を紹介する
    this.pReceiveText = this.bluetoothService.decodeText(result.value);
  }
  else if(result.status === 'readRequested') {
    this.updateMessage('read 要求を受信しました');
    
    // 処理中断用のタイマーを解除する
    if(waitTimer) {
      clearTimeout(waitTimer);
    }
    
    // result.requestId で特定できる read 要求に対して、応答してから終了する
    // TODO : BluetoothService#encodeText() は後で実装を紹介する
    this.peripheralService.respond({
      requestId: result.requestId,
      value: this.bluetoothService.encodeText(pReceiveText)
    })
      .then(() => {
        // 応答に成功したら終了処理を呼ぶ
        this.destroyPeripheral();
      })
      .catch(() => {
        // 応答に失敗した場合も終了処理を呼ぶ
        this.destroyPeripheral();
      });
  }
  else {
    // その他の場合は今回は特に何もしない
    this.updateMessage(`次のイベントが発生しました : ${result.status}`);
  }
  
  // いずれの処理の場合も画面を強制的に再描画するためココでも ChangeDetectorRef を呼んでおく
  this.changeDetectorRef.detectChanges();
})
  .then(() => {
    this.updateMessage('ペリフェラル初期化完了・サービス追加開始');
    /* 以下略 */
    });

initializePeripheral のコールバック関数には引数が1つ渡されており (ココでいう result)、この中の status プロパティが、コールバックの実行理由を示している。

取りうる status プロパティは以下で確認できる。

  • status => enabled = Bluetooth is enabled
  • status => disabled = Bluetooth is disabled
  • status => readRequested = Respond to a read request with respond(). Characteristic (Android/iOS) or Descriptor (Android)
  • status => writeRequested = Respond to a write request with respond(). Characteristic (Android/iOS) or Descriptor (Android)
  • status => subscribed = Subscription started request, use notify() to send new data
  • status => unsubscribed = Subscription ended request, stop sending data
  • status => notificationReady = Resume sending subscription updates (iOS)
  • status => notificationSent = Notification has been sent (Android)
  • status => connected = A device has connected
  • status => disconnected = A device has disconnected
  • status => mtuChanged = MTU has changed for device

細かく状況を確認しようと思えば、セントラル端末が接続してきたときに connected ステータスのコールバックが実行されるし、今回はサンプルに含んでいないがセントラル側で subscribe() を使った場合は subscribed ステータスのコールバックが発生する。今回の例では、このうち readRequestedwriteRequested のイベントを検知している。その他のプロパティはステータスに応じて requestId が渡されたり色々と変化する。

後で実装するが、セントラル側では

  1. 先に write 要求によってペリフェラル端末に向けてメッセージを送信し、
  2. 次に read 要求によってペリフェラル端末にメッセージを応答するよう要求する

という処理をするつもりである。そこで、ペリフェラル端末側の実装としては

  1. write 要求を受け取ったら (= writeRequested を検知したら) 受け取ったメッセージをテキストボックスに出力し、
  2. read 要求を受け取ったら (= readRequested を検知したら) テキストボックスの文字列をセントラル端末に返送する

という処理を用意しておく。

それ以外のイベントココでは検知せず素通りさせるが、ChangeDetectorRef#detectChanges() は実行しておくと良い。特に writeRequested の際に受信テキストを表示するところが正しく検知されないので、ココのために実行しておく。

readRequested を受け取ったら、テキストを返送してから、ペリフェラル端末の終了処理 peripheralDestroy() を呼び出しておく。

updateMessage() による進捗メッセージ表示は、実際は高速で連続して更新されるので、通信が始まったら一瞬で送受信 → 終了処理、と流れると思われる。

テキストのエンコード・デコード

BluetoothService#encodeText() と、BluetoothService#decodeText() というメソッドが突如登場したと思う。これは送信する文字列を Base64 文字列にエンコードし、受信したテキストはデコードするための処理。

BluetoothService というサービスクラスを作り、以下のように実装する。

@Injectable()
export class BluetoothService {
  /** 引数の文字列を Base64 エンコードする */
  encodeText(str: string): string {
    const encodedString = btoa(this.windowRefService.nativeWindow.unescape(encodeURIComponent(str)));
    return encodedString;
  }
  
  /** 引数の Base64 文字列をデコードする */
  decodeText(encodedString: string): string {
    const str = decodeURIComponent(this.windowRefService.nativeWindow.escape(atob(encodedString)));
    return str;
  }
}

cordova-plugin-bluetoothle はテキストのエンコード・デコード用メソッドを提供しているのだが、日本語に対応していないため、以前紹介した Base64 エンコードの処理を使い回すことにした。

addServicestartAdvertising のオプション設定

ここまでで、ペリフェラルの初期設定およびイベント検知 (initializePeripheral)、通信終了処理の実装が終わった。

残りは通信開始時のオプション設定だ。これは API リファレンスを参考に、以下のように実装する。前回作成した定数クラス bluetoothConstants を使う。

// 省略
.then(() => {
  this.updateMessage('ペリフェラル初期化完了・サービス追加開始');
  return this.peripheralService.addService({
    // 追加するサービスの UUID を指定する
    service: bluetoothConstants.serviceUuid,
    // サービスに紐付くキャラクタリスティック : 配列で複数指定できるが今回は1つのみ
    characteristics: [{
      // キャラクタリスティック UUID
      uuid: bluetoothConstants.characteristicUuid,
      // 許可設定
      permissions: {
        read: true,
        write: true
      },
      // 通信の設定
      properties: {
        read: true,
        writeWithoutResponse: true,  // レスポンスなしで write() させる設定。true の場合は write に対し respond() できなくなる
        write: true,
        notify: true,
        indicate: true
      }
    }]
  });
})
.then(() => {
  this.updateMessage('サービス追加完了・アドバタイジング開始');
  // アドバタイジングを開始するサービスを指定する
  return this.peripheralService.startAdvertising({
    services: [bluetoothConstants.serviceUuid],  // iOS 向けの書き方 : 配列
    service: bluetoothConstants.serviceUuid,  // Android 向けの書き方もついでに
    // アドバタイジング名
    name: bluetoothConstants.advertisingName
  });
})
// 以後略

このようになる。


これでペリフェラル側の実装が完了した。

次回はセントラル側の実装を行い、今回用意したペリフェラル端末とやり取りできるようにしていこうと思う。