はじめに
Bluetooth LEでWindowsとAndroidの通信をする機会があったのでその記録
なんか謎のしきたりが多くてやたら苦労した
Bluetooth LEについて
センサーとかで使用するための省電力なBluetooth
とはいえ、使い方は普通のBluetoothとだいぶ違っている
概略
Bluetooth LEはセントラル(今回はWindows機)とペリフェラル(今回はAndroid)で通信する
一般的にはペリフェラルがセンサーなど、小さい機器でセントラルがセンサーの値などを取得して使う側
通信的にはペリフェラルがサーバとなる(通信を待ち受けて、来たら応答する)
サーバのペリフェラルは、Serviceを提供していてい、Serviceには複数の値(Characteristics)がある
このCharacteristicsの読み書きがBluetooth LEの通信というわけ
Service, CharacteristicsはそれぞれUUIDで特定され、一般的なものはUUIDが決められているが、まあ作っても良いらしい
UUIDなのでかぶらないはず
ちなみに16進数4桁の短縮表現があるけど、こちらは登録制らしいので、自分で独自のService, Characteristicsを使うなら128bitのUUIDで
通信の手順
手順自体はわりと簡単
- ペリフェラル側で Advertise 開始
- セントラル側で Advertise しているペリフェラルを探す
- Advertise のパケットを受信したら Service UUID を調べて対象の機器を特定
- 対象の機器に接続して Service, Characteristics のリスト取得
- Characteristics を読み書き
という感じ
BLEはとにかくパケットが小さい(= MTU が小さい)ので、大きいサイズのデータを読み書きするときはちょっと工夫が必要
Windows と Android で通信するときは Windows が勝手に MTU を500バイトくらいまで大きくしているっぽいけど
実装
セントラル側プロジェクトの作成
セントラル側は今回Windowsマシン
WindowsでBluetoothLEを使う場合はUWPアプリで使うWinRT APIを使う必要がある
で、さっそくここでハマる
C#で作ろうとしていたけど、APIのバージョンとかの問題でビルドが通らなかったり、ビルドが通っても接続がエラーになったりで全然動かない(まあ、一般的なUWPアプリじゃなくて、コンソールアプリでやろうとしていたのが悪い気がするけど)
結局C++/WinRTを試してみたら、なぜかこっちは割とすんなり動いたので、C++/WinRTでやったとさ
というわけで色々苦労したので、作り方をちょっと詳しく残しておく
使うのはVisual Studio 2022
起動して、新しいプロジェクトでWindows コンソールアプリケーション(C++/WinRT)で作るんだけど、場合によってはVisual Studioのアップデートと、個別のコンポーネントから「C++ WinUIアプリ開発ツール」をインストールする必要がある
こうすると、新しいプロジェクトで「Windows コンソールアプリケーション(C++/WinRT)」が選択できるようになる
そして、プラットフォームのバージョン選択が出てくる
ここが問題で、最小バージョンを Windows 10, version 2004 (10.0; ビルド19041) 以降にしないとBluetooth LE機能が使えないらしい
今更Windows 10はいらないと思うけど、今回は最小バージョン Windows 10, version 2104 (10.0; ビルド 20348) でやってみている(ターゲットバージョンはそのままで良いはず)
これで作ると Windows::Devices::Bluetooth, Windows::Devices::Bluetooth::Advertisement なんかの namespace が使えるはず
セントラル側コード
最初にAdvertiseしている機器のスキャン
BluetoothLEAdvertisementWatcherというのを使って、見つかると callback が来るのでそこで処理する
実際のコードはこんな感じ(なぜかC#でうまくいかないのでC++/WinRT)
一般的にはAdvertiseのパケットに Service UUID が入っているが、前述のように MTU が小さくて、128bitのUUIDを Advertise のパケットに入れるとすぐにサイズオーバーするので、応答の Data section に入っていることもあるらしい
というわけで、一応 Data section も読み込んでチェックしている
ここでは、targetServiceUuid に使いたい Service UUID を入れておいて、それを探している
最初に見つけたもののアドレスが得られるが、複数同じ Service UUID を持つ機器がある場合はちょっと面倒(後述)
void BleInterface::ScanDevice()
{
m_bDeviceFound = false;
BluetoothLEAdvertisementWatcher watcher;
watcher.ScanningMode(BluetoothLEScanningMode::Active);
watcher.Received([&](BluetoothLEAdvertisementWatcher const& sender, BluetoothLEAdvertisementReceivedEventArgs const& args) { AdvertisementCallback(sender, args); });
watcher.Start();
// スキャン中の待ち
for (int i = 0; i < 500 && !m_bDeviceFound; i++) {
Sleep(10);
}
watcher.Stop();
}
void BleInterface::AdvertisementCallback(BluetoothLEAdvertisementWatcher const&, BluetoothLEAdvertisementReceivedEventArgs const& args)
{
auto adv = args.Advertisement();
for (auto const& uuid : adv.ServiceUuids()) {
if (IsEqualGUID(uuid, targetServiceUuid)) {
m_bDeviceFound = true;
m_DeviceAddress = args.BluetoothAddress();
}
}
for (auto const& datasec : adv.DataSections()) {
uint8_t type = datasec.DataType();
if (0x06 != type && 0x07 != type) {
continue;
}
auto buf = datasec.Data();
auto reader = DataReader::FromBuffer(buf);
while (16 <= reader.UnconsumedBufferLength()) {
uint8_t uuid_bytes[16];
reader.ReadBytes(uuid_bytes);
// Windows GUIDはリトルエンディアンの並び替えが必要
GUID advertised_uuid = {
(uint32_t)(uuid_bytes[3] << 24 | uuid_bytes[2] << 16 | uuid_bytes[1] << 8 | uuid_bytes[0]),
(uint16_t)(uuid_bytes[5] << 8 | uuid_bytes[4]),
(uint16_t)(uuid_bytes[7] << 8 | uuid_bytes[6]),
{ uuid_bytes[8], uuid_bytes[9], uuid_bytes[10], uuid_bytes[11], uuid_bytes[12], uuid_bytes[13], uuid_bytes[14], uuid_bytes[15] }
};
if (IsEqualGUID(advertised_uuid, targetServiceUuid)) {
m_bDeviceFound = true;
m_DeviceAddress = args.BluetoothAddress();
}
}
}
}
通信の相手を見つけたら取得したアドレスを使って接続、ペリフェラルが持つ characteristics の確認、characteristics の読み書きと進む
最初にウェイトが入っているけれど、これが無いとたまに FromBluetoothAddressAsync がやたら長い時間待った挙句失敗する
また、同じ Service/Characteristics を持つ複数の機器がある場合、一度にスキャンして後から順番に接続しようとすると失敗する
スキャンして接続、通信して切断を繰り返さないといけないので注意 (一度接続した機器はスキャンの時に飛ばすとか、割と面倒だけど)
int BleInterface::Connect()
{
// scan後すぐに接続するとエラーになることがあるのでちょっと待つ
// 2秒は長すぎる気もするけど、とりあえず
Sleep(2000);
// ペリフェラルに接続
auto dev = BluetoothLEDevice::FromBluetoothAddressAsync(m_DeviceAddress).get();
if (NULL == dev) {
return BLEERROR_DEVICE;
}
connectedDevice = dev;
// service を取得
auto service_res = dev.GetGattServicesForUuidAsync(targetServiceUuid, BluetoothCacheMode::Uncached).get();
if (service_res.Status() != GattCommunicationStatus::Success) {
return BLEERROR_GATTSERVICE;
}
// service は複数持てるので、リストになっている
auto service_list = service_res.Services();
if (0 == service_list.Size()) {
return BLEERROR_SERVICE;
}
// とりあえず決め打ちで先頭の service を使う
connectedService = service_list.GetAt(0);
// characteristics を取得
auto char_res = connectedService.GetCharacteristicsAsync(BluetoothCacheMode::Uncached).get();
if (char_res.Status() != GattCommunicationStatus::Success) {
return BLEERROR_CHARACTERISTICS;
}
// characteristics も複数あるのでリスト
auto char_list = char_res.Characteristics();
for (uint32_t i = 0; i < char_list.Size(); i++) {
// ここでポインタを保存してはいけない、実体で
characteristicsObj[i] = char_list.GetAt(i);
// characteristics の UUID を確認するなら、ここで characteristicsObj[i].Uuid() をチェックすればよい
}
return BLEERROR_NONE;
}
characteristics の読み書きはこんな感じ
int BleInterface::ReadCharacteristics(int characteristics, uint8_t* dest_buf, int dest_size)
{
if (NULL == connectedDevice) {
Log("ReadCharacteristics: not connected");
return 0;
}
if (characteristicsNum <= characteristics) {
Log("ReadCharacteristics: index error");
return 0;
}
if (nullptr == characteristicsObj[characteristics]) {
Log("ReadCharacteristics: characteristics not found");
return 0;
}
GattCharacteristic charobj = characteristicsObj[characteristics];
try {
auto read_res = charobj.ReadValueAsync(BluetoothCacheMode::Uncached).get();
if (read_res.Status() == GattCommunicationStatus::Success) {
auto buf = read_res.Value();
auto reader = DataReader::FromBuffer(buf);
std::vector<uint8_t> data(reader.UnconsumedBufferLength());
reader.ReadBytes(data);
int data_size = (int)data.size();
int ret = 0;
if (data_size <= dest_size) {
memcpy(dest_buf, &data[0], data_size);
ret = data_size;
}
else {
memcpy(dest_buf, &data[0], dest_size);
ret = dest_size;
}
return ret;
}
else {
return 0;
}
}
catch (winrt::hresult_error const& ex) {
Log("ReadCharacteristics exception: %08X", ex.code());
return 0;
}
}
int BleInterface::WriteCharacteristics(int characteristics, const uint8_t* src_buf, int src_size)
{
if (NULL == connectedDevice) {
return 0;
}
if (characteristicsNum <= characteristics) {
return 0;
}
if (nullptr == characteristicsObj[characteristics]) {
return 0;
}
GattCharacteristic charobj = characteristicsObj[characteristics];
try {
DataWriter writer;
writer.WriteBytes(array_view<const uint8_t>(src_buf, src_buf + src_size));
auto write_res = charobj.WriteValueAsync(writer.DetachBuffer()).get();
if (write_res == GattCommunicationStatus::Success) {
return src_size;
}
}
catch (winrt::hresult_error const& ex) {
Log("WriteCharacteristics exception: %08X", ex.code());
}
return 0;
}
Android側プロジェクトの作成
Bluetooth LEのペリフェラルになる機能はAPIレベル21以降が必要らしい
ただ、だんだん機能が増えていっているのでとりあえず今回は割と新しめのAPIレベル31で作った(Advertise callback が30以降とのこと)
あと、Bluetoothを使用するための権限が必要
とりあえずマニフェストに以下を追加した(ChatGPT先生に言われた通りだけど、全部は必要ないかも)
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
Android 側コード
まず Bluetooth LE を使うための権限を確認する必要がある
(あの○○を使いますみたいなダイアログが出るやつ)
Build.VERSION.SDK_INT < Build.VERSION_CODES.S のチェックは不要っぽいけど、一応ChatGPT先生の言った通りにしている
val permissions = mutableListOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
permissions.add(Manifest.permission.BLUETOOTH)
permissions.add(Manifest.permission.BLUETOOTH_ADMIN)
}
if (!hasPermissions(permissions.toTypedArray())) {
ActivityCompat.requestPermissions(this, permissions.toTypedArray(), 1)
}
次は Service を設定して Advertise する
SERVICE_UUID は今回提供する Service を特定するものなので、独自のサービスなら UUID を生成して使う
Characteristics は、読み込み用、書き込み用で2つ登録しているが、1つで読み書き両方可能ということもできる
基本的にはセントラルからの要求が来るとコールバックされるので、そこで処理する
onServiceAdded の中で成功してから Advertise を開始することが非常に重要
最初 ChatGPT先生は gattServer?.addService(service) してから300msほど待って開始すれば良いと言っていたのに、それでやったらすごく不安定で、変な characteristics の UUID が来たりしてハマりまくった
public fun initBluetoothLe() {
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter
if (null == bluetoothAdapter || !bluetoothAdapter.isEnabled) {
Log.e("BLE", "Bluetooth disabled")
return
}
bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
if (null == bluetoothLeAdvertiser) {
Log.e("BLE", "Advertise not supported")
return;
}
gattServer = bluetoothManager.openGattServer(this, gattServiceCallback)
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
val ch_r = BluetoothGattCharacteristic(CHARACTERISTICS_R_UUID, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ);
val ch_w = BluetoothGattCharacteristic(CHARACTERISTICS_W_UUID, BluetoothGattCharacteristic.PROPERTY_WRITE, BluetoothGattCharacteristic.PERMISSION_WRITE);
service.addCharacteristic(ch_r)
service.addCharacteristic(ch_w)
gattServer?.addService(service)
}
// 各種コールバック
private val gattServiceCallback = object : BluetoothGattServerCallback() {
// 接続状態が変わったとき
override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) {
super.onConnectionStateChange(device, status, newState)
if (BluetoothProfile.STATE_CONNECTED == newState) {
Log.i("BLE", "Connected: ${device?.address}")
}
else if (BluetoothProfile.STATE_DISCONNECTED == newState) {
Log.i("BLE", "Disconnected: ${device?.address}")
}
}
// Service を登録したとき
// この中で startAdvertising を呼ぶのが非常に重要
override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
super.onServiceAdded(status, service)
Log.d("GattServer", "Service added: ${service?.uuid}, status: $status")
if (BluetoothGatt.GATT_SUCCESS == status) {
startAdvertising()
}
}
// characteristics Read を受信したとき
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onCharacteristicReadRequest(
device: BluetoothDevice?,
requestId: Int,
offset: Int,
characteristic: BluetoothGattCharacteristic?
) {
super.onCharacteristicReadRequest(device, requestId, offset, characteristic)
if (null == characteristic) {
Log.i("BLE", "onCharacteristicReadRequest: null")
}
else {
Log.i("BLE", "onCharacteristicReadRequest: ${characteristic.uuid}")
}
var ret = "error".toByteArray(StandardCharsets.US_ASCII)
if (null == characteristic) {
LogOut("No characteristics")
Log.e("BLE", "No characteristics")
}
else if (characteristic.uuid.equals(CHARACTERISTICS_R_UUID)) {
// とりあえず適当に文字列を返しておく
ret = "read characteristics test".toByteArray(StandardCharsets.US_ASCII);
Log.i("BLE", "Characteritics Read")
}
else {
Log.e("BLE", "Unknown characteristics")
}
// offset の処理
// MTU が小さいので、offset 指定して複数回に分けて読み込めるようになっている
// セントラル側は、受信したサイズずらして Read request を送信を繰り返すことになる
var retoff = ret
if (offset < ret.size) {
retoff = Arrays.copyOfRange(ret, offset, ret.size)
}
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, retoff)
}
// characteristics write を受信したとき
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onCharacteristicWriteRequest(
device: BluetoothDevice?,
requestId: Int,
characteristic: BluetoothGattCharacteristic?,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray?
) {
super.onCharacteristicWriteRequest(
device,
requestId,
characteristic,
preparedWrite,
responseNeeded,
offset,
value
)
if (null == characteristic) {
Log.i("BLE", "onCharacteristicWriteRequest: null")
}
else {
Log.i("BLE", "onCharacteristicWriteRequest: ${characteristic.uuid}")
}
var errorOccured = false
if (null == characteristic) {
Log.e("BLE", "No characteristics")
errorOccured = true;
}
else if (characteristic.uuid.equals(CHARACTERISTICS_W_UUID)) {
Log.i("BLE", "write keyexchange")
if (null == value) {
errorOccured = true
}
else {
errorOccured = false;
// ここで value を何かに書き込む
}
}
else {
Log.e("BLE", "Unknown characteristics")
}
Log.i("BLE", "write request: ${value?.size}")
// 返答が必要な通信なら返す
if (responseNeeded) {
if (errorOccured) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null)
}
else {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
}
}
private fun startAdvertising() {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.build()
bluetoothLeAdvertiser?.startAdvertising(settings, data, advertiseCallback)
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
Log.i("BLE", "Start advertising");
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
Log.e("BLE", "Advertising error: $errorCode")
}
}