2024-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本体を
などを参考にインストールする。ただしあまりに最新だと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 をコーディング
JavaScript | main.js | GitHub 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に処理を委譲する。
- 接続が切断されたらふたたびアドバタイズを開始する。
JavaScript | characteristic.js | GitHub 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・クライアント編】
※本記事内容の無断転載を禁じます。
ご連絡は以下アドレスまでお願いします★
オープンソースリップシンクエンジンSadTalkerをDebianで動かす
ファイアウォール内部のOpenAPI/FastAPIのdocsを外部からProxyPassで呼ぶ
Debian 12でsshからshutdown -h nowしても電源が切れない場合
【Windows&Mac】アプリのフルスクリーンを解除する方法
Debian 12でtsコマンドが見つからないcommand not found
Debian 12でsyslogやauth.logが出力されない場合
Debian 12で固定IPアドレスを使う設定をする
Debian 12 bookwormでNVIDIA RTX4060Ti-OC16GBを動かす
【Debian】apt updateでCD-ROMがどうのこうの言われエラーになる場合
【Windows10】リモートデスクトップ間のコピー&ペーストができなくなった場合の対処法
Windows11+WSL2でUbuntuを使う【2】ブリッジ接続+固定IPの設定
【Apache】サーバーに同時接続可能なクライアント数を調整する
GitLabにHTTPS経由でリポジトリをクローン&読み書きを行う
【C/C++】小数点以下の切り捨て・切り上げ・四捨五入
Intel Macbook2020にBootCampで入れたWindows11 Pro 23H2のBluetoothを復活させる
【PHP】Mail/mimeDecodeを使ってメールの中身を解析(準備編)
【ひかり電話+VoIPアダプタ】LANしか通ってない環境でアナログ電話とFAXを使う
Windows11のコマンドプロンプトでテキストをコピーする