なごるふ

UnityとかArduinoとか気になったことを

【Unity】BLE接続用のAndroidプラグインを書く

前回の記事ではESP32とAE-TYBLE16を繋いで、Androidのデバッグアプリから接続を試してみました。

前回の記事を書いたあとに、こちらの有料Assetを使ったアプリで接続を試してみたんですが、どうしても接続できず、仕方なくUnity用のAndroidのプラグインを1から作成してみました。

Android側の処理は以下のサイトを参考にしています。

https://blog.fenrir-inc.com/jp/2013/10/bluetooth-le-android.html

開発環境

  • Mac Book (10.13.6)
  • Unity 2019.4.6f1

Androidプラグイン

Android Javaは普段書かないので書き方がおかしいかもしれません。

UnityプロジェクトのAssets/Plugins/Android以下追加します。

Android10以降だと位置情報の取得にユーザーの許可を取る必要があるため、今回はUnity上でターゲットAPIをAndroid 9以下にして回避します。

Android10以降では位置情報の許可を取らないとエラーが出ずにスキャン結果が返ってこないというところで躓きました。

Notificationのリクエストを送ることが重要みたいで、これがないと接続できませんでした。

package unity.android.plugin;

import android.content.Context;
import java.lang.Exception;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.le.*;

import com.unity3d.player.UnityPlayer;

import java.util.List;
import java.util.ArrayList;
import java.util.UUID;

public class BluetoothLE
{
    private static final String RECEIVE_OBJECT_NAME = "BluetoothLEReceiver";
    private static final UUID CLIENT_CHARACTERISTIC_CONFIG = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");

    private BluetoothAdapter adapter;
    private BluetoothLeScanner scanner;
    
    private BluetoothGatt gatt;
    private BluetoothDevice device;
    
    // 初期化.
    public void initialize()
    {
        //Bluetoothアダプターを初期化
        BluetoothManager manager = (BluetoothManager)UnityPlayer.currentActivity.getSystemService(Context.BLUETOOTH_SERVICE);
        adapter = manager.getAdapter();
        
        scanner = adapter.getBluetoothLeScanner();
        
        unitySendMessage("InitializeCallback");
    }

    // スキャン開始.
    public void startScan()
    {
        ScanSettings.Builder scanSettings = new ScanSettings.Builder();
        scanSettings.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY);
        ScanSettings settings = scanSettings.build();

        // NOTE: Target Android9 API28まではマニフェスト追加のみで動作 Android10以降はユーザー許可が必要.
        scanner.startScan(null, settings, scanCallback);
    }

    // スキャンの停止.
    public void stopScan()
    {
        scanner.stopScan(scanCallback);
    }
    
    // デバイス接続.
    public void connectToDevice(String address)
    {
        device = adapter.getRemoteDevice(address);
        if (device == null)
        {
            return;
        }

        if (gatt != null)
        {
            gatt.disconnect();
        }

        gatt = device.connectGatt(UnityPlayer.currentActivity, true, gattCallback);
    }
    
    // デバイス接続解除.
    public void disconnectDevice()
    {
        if (gatt != null)
        {
            gatt.disconnect();
            gatt = null;
        }
    }
    
    private ScanCallback scanCallback = new ScanCallback()
    {
        @Override
        public void onScanResult(int callbackType, ScanResult result)
        {
            if(result.getDevice() == null)
            {
                return;
            }
            
            // 検出したデバイス情報を通知.
            String deviceName = result.getDevice().getName();
            String address = result.getDevice().getAddress();
            unitySendMessage("ScanCallback", deviceName, address);
        }
    };
    
    private BluetoothGattCallback gattCallback = new BluetoothGattCallback()
    {
        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int state)
        {
            if (state == BluetoothProfile.STATE_CONNECTED)
            {
                // 接続成功.
                unitySendMessage("ConnectCallback");
            }
            else if (state == BluetoothProfile.STATE_DISCONNECTED)
            {
                // 接続解除.
                unitySendMessage("DisconnectCallback");
            }
        }

        @Override
        public void onServicesDiscovered(BluetoothGatt gatt, int status)
        {
            if (status == BluetoothGatt.GATT_SUCCESS)
            {
                // 検出したサービスとCharacteristicを通知.
                for (BluetoothGattService service : gatt.getServices())
                {
                    for (BluetoothGattCharacteristic characteristic : service.getCharacteristics())
                    {
                        unitySendMessage("DiscovereCharacteristicCallback", service.getUuid().toString(), characteristic.getUuid().toString());
                    }
                }
            }
        }
    };
    
    // サービスを検出.
    public void discoverServices()
    {
        gatt.discoverServices();
    }
    
    // Characteristicに対してNotificationの受信を要求.
    public void requestNotification(String serviceUUID, String notificationUUID)
    {
        BluetoothGattService service = gatt.getService(UUID.fromString(serviceUUID));
        BluetoothGattCharacteristic characteristic = service.getCharacteristic(UUID.fromString(notificationUUID));
        
        gatt.setCharacteristicNotification(characteristic, true);
        BluetoothGattDescriptor notification_descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG);
        notification_descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
        gatt.writeDescriptor(notification_descriptor);
    }
    
    // Unity側にメッセージ通知.
    private void unitySendMessage(String... params)
    {
        String param = String.join(",", params);
        UnityPlayer.UnitySendMessage(RECEIVE_OBJECT_NAME, "PluginMessage", param);
    }
    
    // メッセージ送信.
    public boolean sendMessage(String serviceUUID, String writeCharacteristicUUID, String message)
    {
        try
        {
            byte[] bytes = message.getBytes("UTF-8");

            // 書き込み.
            BluetoothGattService service = gatt.getService(UUID.fromString(serviceUUID));
            BluetoothGattCharacteristic characteristic = service.getCharacteristic(UUID.fromString(writeCharacteristicUUID));
            characteristic.setValue(bytes);
            return gatt.writeCharacteristic(characteristic);
        }
        catch(Exception e)
        {
            unitySendMessage("ErrorCallback", e.getMessage());
        }

        return false;
    }
}

Android Manifest

こちらもUnityプロジェクトのAssets/Plugins/Android以下追加します。

当初Android10以降を対象にしていたためうまく動かず、その過程でパーミッションを色々追加したため少し過剰かもしれません。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.unity3d.player" xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
    <application>
        <activity android:name="com.unity3d.player.UnityPlayerActivity" android:theme="@style/UnityThemeSelector">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
        </activity>
    </application>
</manifest>

Unity

Androidプラグインを呼ぶ側

Unity側からAndroid側のメソッドを呼ぶための処理です。

using System;
using UnityEngine;

public static class BluetoothLE
{
    private static AndroidJavaObject androidBle;
    private static BluetoothLEReceiver receiver;

    static BluetoothLE()
    {
        androidBle = new AndroidJavaObject("unity.android.plugin.BluetoothLE");
        receiver = BluetoothLEReceiver.GetOrCreateReceiver();
    }

    public static void Initialize(Action onInitialize, Action<string> onError)
    {
        receiver.OnInitialize = onInitialize;
        receiver.OnError = onError;

        androidBle.Call("initialize");
    }

    public static void StartScan(Action<string, string> onScan)
    {
        receiver.OnScan = onScan;

        androidBle.Call("startScan");
    }

    public static void StopScan()
    {
        androidBle.Call("stopScan");
    }

    public static void Connect(string address, Action onConnect, Action onDisconnect)
    {
        receiver.OnConnect = onConnect;
        receiver.OnDisconnect = onDisconnect;

        androidBle.Call("connectToDevice", address);
    }

    public static void Disconnect()
    {
        androidBle.Call("disconnectDevice");
    }

    public static void DiscoverService(Action<string, string> onDiscovereCharacteristic)
    {
        receiver.OnDiscovereCharacteristic = onDiscovereCharacteristic;

        androidBle.Call("discoverServices");
    }

    public static bool SendMessage(string serviceUUID, string charcteristicUUID, string message)
    {
        return androidBle.Call<bool>("sendMessage", serviceUUID, charcteristicUUID, message);
    }

    public static void RequestNotification(string serviceUUID, string charcteristicUUID)
    {
        androidBle.Call("requestNotification", serviceUUID, charcteristicUUID);
    }
}

Androidプラグインの受け側

AndroidからUnityへの通知はGameObject経由でないといけないため、インスタンス化したReceiverを用意します。

using System;
using UnityEngine;

public class BluetoothLEReceiver : MonoBehaviour
{
    public static BluetoothLEReceiver instance;

    public Action OnInitialize;
    public Action<string> OnError;
    public Action<string, string> OnScan;
    public Action OnConnect;
    public Action OnDisconnect;
    public Action<string, string> OnDiscovereCharacteristic;

    public static BluetoothLEReceiver GetOrCreateReceiver()
    {
        if (instance == null)
        {
            instance = new GameObject("BluetoothLEReceiver").AddComponent<BluetoothLEReceiver>();
            DontDestroyOnLoad(instance.gameObject);
        }

        return instance;
    }

    public void PluginMessage(string message)
    {
        Debug.Log(message);
        var param = message.Split(',');

        if (param.Length == 0)
        {
            return;
        }

        switch (param[0])
        {
            case "InitializeCallback":
                OnInitialize?.Invoke();
                break;

            case "ScanCallback":
                OnScan?.Invoke(param[1], param[2]);
                break;

            case "ConnectCallback":
                OnConnect?.Invoke();
                break;

            case "DisconnectCallback":
                OnConnect?.Invoke();
                break;

            case "DiscovereCharacteristicCallback":
                OnDiscovereCharacteristic?.Invoke(param[1], param[2]);
                break;

            case "ErrorCallback":
                OnError?.Invoke(param[1]);
                break;
        }
    }
}

テストアプリ側

画面をタップすると値をAE-TYBLE16に送信するようにしています。

UUIDは記載したらマズそうなので伏せておきます。

ScanとDiscovereをしてみて、結果を出力すればきっとわかります。

using UnityEngine;
using UnityEngine.UI;

public class TYBLE16 : MonoBehaviour
{
    public Text status;

    /* スキャン結果を見て追加 */
    private const string DEVICE_NAME = "TYSA-B 3.0.0";
    private const string DEVICE_ADDRESS = "**:**:**:**:**:**";
    private const string SERVICE_UUID = "**************************************";
    private const string NOTIFICATION_CHARACTERISTIC_UUID = "**************************************";
    private const string WRITE_CHARACTERISTIC_UUID = "**************************************";

    private bool connected;

    void Start()
    {
        BluetoothLE.Initialize(OnInitialize, OnError);
    }

    void OnInitialize()
    {
        BluetoothLE.StartScan(OnScan);
        status.text = "Scanning";
    }

    void OnScan(string deviceName, string address)
    {
        if (deviceName == DEVICE_NAME && address == DEVICE_ADDRESS)
        {
            BluetoothLE.StopScan();
            BluetoothLE.Connect(DEVICE_ADDRESS, OnConnect, OnDisconnect);
            status.text = "Connecting";
        }
    }

    void OnConnect()
    {
        // 接続できたらサービスを検出.
        BluetoothLE.DiscoverService(OnDiscovereCharacteristic);
    }

    void OnDisconnect()
    {
        // 接続が切れたら再スキャン開始.
        BluetoothLE.StartScan(OnScan);

        status.text = "Scanning";
        connected = false;
    }

    void OnDiscovereCharacteristic(string serviceUUID, string characteristicUUID)
    {
        if (serviceUUID == SERVICE_UUID && characteristicUUID == WRITE_CHARACTERISTIC_UUID)
        {
            BluetoothLE.RequestNotification(SERVICE_UUID, NOTIFICATION_CHARACTERISTIC_UUID);

            status.text = "RequestNotification";
            connected = true;
        }
    }

    private void Update()
    {
        if (connected && Input.GetMouseButtonDown(0))
        {
            // 画面をタップしたらメッセージ送信.
            BluetoothLE.SendMessage(SERVICE_UUID, WRITE_CHARACTERISTIC_UUID, "Hello World" + "\n");
        }
    }

    void OnError(string message)
    {
        Debug.Log(message);
    }
}

さいごに

これで無事に自前のAndroidアプリからAE-TYBLE16にBLE接続ができました。

しかしながら、AE-TYBLE16が受信したデータをマイコン側で受け取るまでに一定のラグがあるようです。

これはAE-TYBLE16の独自ファームウェアの機能によるものかと思いますが、 連続して値を送信すると最後に受信したタイミングから約1秒ほど待った後に蓄積したデータをまとめてマイコンに送っているような挙動になっています。

このままではスライダー操作で変化する値を送信するような使い方は難しそうなので、受信後に即時でマイコン側に値を渡せないか検証してみます。

2022/01/14 追記

受信が遅れる原因がわかりました。

Serial.readString()Serial.Timeoutで設定されている時間(Defaultが1000ms)まで待機するようです。

Serial.read()で1文字ずつ処理をすれば即時で処理できることを確認できました。