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・クライアント編】
※本記事内容の無断転載を禁じます。
ご連絡は以下アドレスまでお願いします★
Wav2Lipのオープンソース版を改造して外部から呼べるAPI化する
Wav2Lipのオープンソース版で静止画の口元のみを動かして喋らせる
【iOS】アプリアイコン・ロゴ画像の作成・設定方法
オープンソースリップシンクエンジンSadTalkerをAPI化してアプリから呼ぶ【2】
オープンソースリップシンクエンジンSadTalkerをAPI化してアプリから呼ぶ【1】
【Xcode】iPhone is not available because it is unpairedの対処法
【Let's Encrypt】Failed authorization procedure 503の対処法
【Debian】古いバージョンでapt updateしたら404 not foundでエラーになる場合
ファイアウォール内部のWindows11 PCにmacOS Sequoiaからリモートデスクトップする
Windows11+WSL2でUbuntuを使う【2】ブリッジ接続+固定IPの設定
進研ゼミチャレンジタッチをAndroid端末化する
Intel Macbook2020にBootCampで入れたWindows11 Pro 23H2のBluetoothを復活させる
VirtualBoxの仮想マシンをWindows起動時に自動起動し終了時に自動サスペンドする
GitLabにHTTPS経由でリポジトリをクローン&読み書きを行う
【C/C++】小数点以下の切り捨て・切り上げ・四捨五入
【PHP】Mail/mimeDecodeを使ってメールの中身を解析(準備編)
【Apache】サーバーに同時接続可能なクライアント数を調整する
タスクスケジューラで変更を適用できません。ユーザーアカウントが不明であるか、パスワードが正しくないか、またはユーザーアカウントにタスクを変更する許可がありません。と出た