アプリケーション開発ポータルサイト
ServerNote.NET
Amazon.co.jpでPC関連商品タイムセール開催中!
カテゴリー【Node.jsJavaScriptRaspberryPIiPhone/iPad
iOS/SwiftUIとRaspberryPi/Bleno間でBluetooth LE通信【1・サーバー編】
POSTED BY
2023-10-12

Bluetooth LE(以下BLE)とは機器間の無線通信方式で、1回のデータ送信量を100~200バイト程度に制限した少量通信規格である。パケットサイズが小さいというだけで、回数も総通信量も制限は特に無いので、汎用通信手段として利用可能である。

iPhoneとRaspberryPiにはお互いこのBLEアダプタが内蔵されている。今回、iPhoneをクライアント(セントラル)、RaspberryPiをサーバー(ペリフェラル)として、以下2つの機能を持つサンプルを作成した。

1、iPhoneから一般文字列を打つと、RaspberryPi側から「あなたは「~」と言いました」と返す。iPhoneはその返却文字列を表示する。
2、iPhoneから画像URL文字列を打つと、RaspberryPiがそのURLの画像をダウンロードし、バイナリデータでiPhoneに返す。iPhoneはその画像を表示する。

【Raspberry Pi(ペリフェラル)側のコーディング】

まずサーバー役になるRaspberry Pi側から構築していく。

1、アダプター状態の確認とUP

hciconfig

hci0:   Type: Primary  Bus: UART
        BD Address: B8:27:EB:51:65:09  ACL MTU: 1021:8  SCO MTU: 64:1
        DOWN
        RX bytes:785 acl:0 sco:0 events:49 errors:0
        TX bytes:1779 acl:0 sco:0 commands:49 errors:0

DOWNとなっていたら使えないので、UPさせる。

sudo hciconfig hci0 up
hciconfig

hci0:   Type: Primary  Bus: UART
        BD Address: B8:27:EB:51:65:09  ACL MTU: 1021:8  SCO MTU: 64:1
        UP RUNNING
        RX bytes:1528 acl:0 sco:0 events:92 errors:0
        TX bytes:2558 acl:0 sco:0 commands:92 errors:0

UP RUNNINGとなればOK。BLEを使うだけならPSCAN,ISCANともに必要ない。
さらにBLEを使うだけならbluetoothd(bluetoothサービス)も必要ない。止めてしまってもOK。

systemctl status bluetooth
systemctl stop bluetooth
systemctl disable bluetooth

2、Node.jsモジュールBlenoのセットアップ

BLE通信にはnpmモジュールのblenoを使う。まずnode本体を

Node.jsのインストール

などを参考にインストールする。ただしあまりに最新だとblenoが動かない可能性があるため、当サイトではv8.2.1あたりを入れた。

nodebrew install-binary v8.2.1
nodebrew use v8.2.1

blenoモジュールインストール(URLで画像取得するためrequestも入れておく)

npm install bleno
npm install request

3、サービスUUIDとキャラクタリスティックUUIDの作成

BLE機器として私はここにいますと発信するため2つのUUIDを登録してあとで発信に使う。UUID生成ツールは以下パッケージインストール

sudo apt install uuid-runtime

UUIDを2つ作成する

uuidgen
54f06857-695e-47e4-aea8-c78184ad6c75

uuidgen
30a5f1bb-61dc-45f0-9c52-2218d080fa77

最初のをサービスUUID、2番目のをキャラクタリスティックUUIDとする。

4、Javascriptコーディング

ホームディレクトリにmy_blenoを作って、そこにmain.js、characteristic.jsを作成する。

mkdir my_bleno
cd my_bleno
ここに
main.js
characteristic.js
をコーディング

JavaScriptmain.jsGitHub Source
//BLE Peripheral sample main

var Bleno = require( 'bleno' );
var BlenoPrimaryService = Bleno.PrimaryService;
var MyCharacteristic = require( './characteristic' );
var MyObj = null;

var MyName = "my-raspbverry-pi";
var ServiceUUID = '54f06857-695e-47e4-aea8-c78184ad6c75';

console.log( 'Bleno - ' + MyName );

Bleno.on( 'stateChange',function( state ) {
  console.log( 'Bleno.on -> stateChange ' + state );
  if ( state === 'poweredOn' ){
    Bleno.startAdvertising( MyName,[ServiceUUID] );
  }
  else{
    Bleno.stopAdvertising();
  }
});

Bleno.on( 'advertisingStart',function( error ) {
  console.log( 'Bleno.on -> advertisingStart ' + (error ? 'error ' + error : 'success') );
  MyObj = new MyCharacteristic();
  Bleno.setServices([new BlenoPrimaryService({uuid:ServiceUUID,characteristics:[MyObj]})]);
});

Bleno.on( 'advertisingStop',function( error ) {
  console.log( 'Bleno.on -> advertisingStop ' + (error ? 'error ' + error : 'success') );
});

Bleno.on('accept', function (clientAddress) {
  console.log("accept: " + clientAddress);
  if( MyObj != null ){
    MyObj.clientAddress = clientAddress;
  }
  Bleno.stopAdvertising();
});

Bleno.on('disconnect', function (clientAddress) {
  console.log("disconnect: " + clientAddress);
  Bleno.startAdvertising( MyName,[ServiceUUID] );
});

  • main.jsは外枠で、ここで自分を発信(アドバタイズ)して、周囲のBLEクライアント機器から発見できるようにする
  • startAdvertisingで、さきほど作成したサービスUUIDを指定する。第二引数でキャラクタリスティックUUIDも発信されるので、クライアント側は望みのサービスUUID・キャラクタリスティックUUIDの組み合わせを指定して接続する。
  • クライアントが接続してきたらstopAdvertisingでアドバタイズをやめて、characteristic.jsに処理を委譲する。
  • 接続が切断されたらふたたびアドバタイズを開始する。

JavaScriptcharacteristic.jsGitHub Source
//BLE Peripheral sample characteristic

var Bleno = require( 'bleno' );
var Util = require( 'util' );
var Fs = require( 'fs' );
var Request = require('request');
var BlenoCharacteristic = Bleno.Characteristic;

var CharacteristicUUID = '30a5f1bb-61dc-45f0-9c52-2218d080fa77';

var MaxValueSize;
var PushCallback;

var RecvCode;
var RecvData;
var RecvSize;
var RecvRead;

var ReplyArray;
var ReplyData;
var ReplySize;
var ReplyRead;

var MyCharacteristic = function() {
  console.log( 'MyCharacteristic - constructor' );

  MyCharacteristic.super_.call( this,
  {
    uuid: CharacteristicUUID,
    properties: ['read', 'write', 'notify'],
    value: null
  } );

  this._value = null;
  this._updateValueCallback = null;
  this.clientAddress = null;
};

Util.inherits( MyCharacteristic,BlenoCharacteristic );

MyCharacteristic.prototype.onSubscribe = function( maxValueSize,updateValueCallback )
{
  MaxValueSize = maxValueSize;
  console.log( 'MyCharacteristic - onSubscribe maxValueSize = ' + MaxValueSize );
  this._updateValueCallback = updateValueCallback;
  PushCallback = updateValueCallback;
  RecvCode = null;
  RecvData = null;
  RecvSize = 0;
  RecvRead = 0;
  ReplyArray = [];
  ReplyData = null;
  ReplySize = 0;
  ReplyRead = 0;
};

MyCharacteristic.prototype.onUnsubscribe = function()
{ console.log( 'MyCharacteristic - onUnsubscribe' );
  MaxValueSize = 0;
  this._updateValueCallback = null;
  PushCallback = null;
  RecvCode = null;
  RecvData = null;
  RecvSize = 0;
  RecvRead = 0;
  ReplyArray = [];
  ReplyData = null;
  ReplySize = 0;
  ReplyRead = 0;
};

// Stream Data Format
// CODE(1byte text) + BODYSIZE(7byte zero filled text) + BODYDATA
// CODE is
// N=Notify only(empty body),
// T=Simple Text Data
// U=URL Text Data
// I=Image Binary Data

MyCharacteristic.prototype.onWriteRequest = function( data,offset,withoutResponse,callback )
{ console.log( 'MyCharacteristic - onWriteRequest length=' + data.length + ",offset=" + offset + ",withoutResponse=" + withoutResponse );

  var index = 0;
  var remain = data.length;
  while( remain > 0 ){
    if( RecvCode == null ){ //ヘッダー
      if( remain < 8 ){
        console.log( 'remain data less than 8 bytes' );
        break; //fatal
      }
      var head = data.slice( index,index + 8 );
      var code = data.slice( index,index + 1 ) + '';
      index++; remain--;
      console.log( 'code is ' + code );
      if( code != 'N' && code != 'T' && code != 'U' && code != 'I' ){
        console.log( 'invalid code' );
        break; //fatal
      }
      RecvCode = code;
      var bytestr = data.slice( index,index + 7 );
      console.log( 'content size str ' + bytestr );
      RecvSize = Number( bytestr );
      console.log( 'content size int ' + RecvSize );
      index += 7; remain -= 7;
      RecvRead = 0;
      RecvData = [];
      continue;
    }
    //ボディ
    var copysize;
    if( RecvRead + remain > RecvSize ){
      copysize = RecvSize - RecvRead;
    }
    else{
      copysize = remain;
    }
    console.log( 'copy data size is ' + copysize );
    var copydata = data.slice( index,index + copysize );
    RecvData.push( copydata );
    RecvRead += copysize;
    index += copysize; remain -= copysize;

    if( RecvCode != null && RecvRead >= RecvSize ){ //読み込み完了
      var alldata = Buffer.concat( RecvData,RecvSize ); //バイト配列をバイナリ1データに
      console.log( 'data complete all size ' + alldata.length );
      var recvcode = RecvCode;
      //初期化
      RecvCode = null;
      RecvData = null;
      RecvSize = 0;
      RecvRead = 0;

      //返却データ分岐

      if(recvcode == 'T'){ //Simple Text
        var repdata = Buffer.from("あなたは「" + String(alldata) + "」と言いました");
        var rephead = Buffer.from('T' + ('0000000' + repdata.length).slice( -7 ));
        var repall = Buffer.concat([rephead,repdata]);
        pushReply(repall);
      }
      else if(recvcode == 'U'){ //画像URL取得
        Request({method: 'GET', url:String(alldata), encoding: null}, function (error, response, body) {
          var repdata = null;
          var rephead = null;
          var repall = null;
          if( error !== null ){
            console.error(error);
            repdata = Buffer.from(String(error));
            rephead = Buffer.from('T' + ('0000000' + repdata.length).slice( -7 ));
            repall = Buffer.concat([rephead,repdata]);
          }
          else{
            console.log('statusCode:', response.statusCode);
            var ctype = response.headers['content-type'];
            console.log('contentType:', ctype);
            if(ctype != null && ctype.indexOf('image') >= 0){
              repdata = Buffer.from(body);
              rephead = Buffer.from('I' + ('0000000' + repdata.length).slice( -7 ));
              repall = Buffer.concat([rephead,repdata]);
            }
            else{
              repdata = Buffer.from("指定URLの画像を取得できません");
              rephead = Buffer.from('T' + ('0000000' + repdata.length).slice( -7 ));
              repall = Buffer.concat([rephead,repdata]);
            }
          }
          pushReply(repall);
        });
      }
    }
  }

  if(!withoutResponse && callback != null){
    callback( this.RESULT_SUCCESS );
  }
};

function pushReply(data)
{
  if(data == null || data.length <= 0){
    return;
  }
  console.log( 'reply complete all size ' + data.length );
  ReplyArray.push( data );
  console.log( "saved to ReplyArray,arrays=" + ReplyArray.length );
  //返却準備完了通知
  if( PushCallback != null ){
    PushCallback( Buffer.from( 'N0000000' ) );
  }
}

MyCharacteristic.prototype.onReadRequest = function( offset,callback )
{ console.log('MyCharacteristic - onReadRequest offset = ' + offset );

  if( ReplyData == null ){
    var replydata = null;
    if(ReplyArray.length > 0){
      replydata = Buffer.from( ReplyArray.shift() );
    }
    if( replydata != null ){
      ReplyData = replydata;
      ReplySize = replydata.length;
      ReplyRead = 0;
    }
    else{ //返すデータはもう無い
      callback( this.RESULT_SUCCESS,'' );
      return;
    }
  }

  var remain = ReplySize - ReplyRead;
  var bytes = remain;
  if( bytes > MaxValueSize ) bytes = MaxValueSize;
  var buf = ReplyData.slice( ReplyRead,ReplyRead + bytes );
  ReplyRead += bytes;
  if( ReplyRead >= ReplySize ){ //1個送信完了
    ReplyData = null;
    ReplySize = 0;
    ReplyRead = 0;
  }

  callback( this.RESULT_SUCCESS,buf ); //返却
};

module.exports = MyCharacteristic;

  • こちらがメイン処理で、初回接続時にonSubscribeで最大パケット長(1回の通信の最大サイズ)とコールバック関数を保存する。コールバック関数を保存しておけば、以降好きなタイミングでクライアントに通知できる。
  • onWriteRequestでクライアントからの入力データを受け取る。1回のパケットサイズは小さいので、ループ処理で数回に分けて受け取れるようにコーディングする。
  • データの受信が完了したら、そのデータの種類に応じて、「あなたは「」と言いました」という返却値を保存、またはURLなら外部にrequestでそのURL画像を取りに行って画像データを保存する。保存が終わったらコールバックでクライアントに通知する。
  • クライアントからの指令が連続で来てもいいように、返却データは配列でどんどんpushしていき、クライアントからの読み取り要求でpopして減らしていく。
  • onReadRequestはコールバックで返却データ準備OKの通知を受けたクライアントが、あらためてその返却データを読みにきたときに呼ばれるので、サーバー側は配列にストックしている返却データをshiftで出して順次送信する。これも、1回のパケットサイズは小さいので、ループ処理で数回に分けて送信できるようにコーディングする。

5、通信データのフォーマット

サーバーとクライアント間でデータ形式の取り決めをしておく。今回は以下のようにデザインした。

Stream Data Format
CODE(1byte text) + BODYSIZE(7byte zero filled text) + BODYDATA
CODE is
N=Notify only(empty body)
T=Simple Text Data
U=URL Text Data
I=Image Binary Data

最大パケットサイズが8未満ということはないはずなので、最低でもヘッダ部の8バイトは読めるので、どのようなデータで、その後に続く生データの長さがわかるので、その量を読み切るまでループする。

例1)クライアントがノーマル文字列「あいうえお」を送るとき
T0000015あいうえお

例2)サーバーが返却データの準備ができたよとクライアントに通知するとき
N0000000

例3)サーバーが返却ノーマル文字列「あなたは「あいうえお」と言いました」と送るとき
T0000059あなたは「あいうえお」と言いました

例4)クライアントが「https://www.testimage155.jp/floppy.png」画像を取ってきて、と指令するとき
U0000038https://www.testimage155.jp/floppy.png

例5)サーバーが返却画像データ(バイナリサイズ5432バイト)を送るとき
I0005432(画像データバイナリ)

6、プログラム起動、待ち受け開始

さきほどのmain.jsを起動する。BLEアダプタ待ち受けはroot権限で動かす必要あり。

sudo /home/pi/.nodebrew/current/bin/node /home/pi/my_bleno/main.js

Bleno - my-raspbverry-pi
Bleno.on -> stateChange poweredOn
Bleno.on -> advertisingStart success
MyCharacteristic - constructor

となれば成功。クライアント(iPhone/SwiftUI)が接続してきたときは

accept: 60:a9:08:a9:15:73
Bleno.on -> advertisingStop success
MyCharacteristic - onSubscribe maxValueSize = 253

などと出力され、上の5、通信データのフォーマットの

例1)→例3)が起こった時

MyCharacteristic - onWriteRequest length=23,offset=0,withoutResponse=false
code is T
content size str 0000015
content size int 15
copy data size is 15
data complete all size 15
reply complete all size 59
saved to ReplyArray,arrays=1
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0

例4)→例2)→例5)が起こった時

MyCharacteristic - onWriteRequest length=50,offset=0,withoutResponse=false
code is U
content size str 0000042
content size int 42
copy data size is 42
data complete all size 42
statusCode: 200
contentType: image/png
reply complete all size 2946
saved to ReplyArray,arrays=1
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0
MyCharacteristic - onReadRequest offset = 0

などと出力される。onReadRequestが複数呼ばれるのは、2946バイトというデータを読むために、クライアントが小さいパケット長で繰り返し呼んで、十数回目でようやく読み込み完了した旨を表している。

次回はiOS/SwiftUIで上記に接続するクライアント(セントラル)を作成します。以下になります。

iOS/SwiftUIとRaspberryPi/Bleno間でBluetooth LE通信【2・クライアント編】

※本記事は当サイト管理人の個人的な備忘録です。本記事の参照又は付随ソースコード利用後にいかなる損害が発生しても当サイト及び管理人は一切責任を負いません。
※本記事内容の無断転載を禁じます。
【WEBMASTER/管理人】
自営業プログラマーです。お仕事ください!
ご連絡は以下アドレスまでお願いします★

☆ServerNote.NETショッピング↓
ShoppingNote / Amazon.co.jp
☆お仲間ブログ↓
一人社長の不動産業務日誌
【キーワード検索】