なごるふ

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

【Unity】iOSのBluetoothLEプラグインを書いてみる

前回の記事の続きで、今度はスマートフォン側でプラレールの速度制御ができるアプリを作っていきます。

以前AE-TYBLE16のBLE接続用にAndroidのUnityプラグインを作成しましたが、普段使いがiPhoneなので今度はiPhone用のプラグインも書いてアプリに組み込んでみます。

Objective-C側の処理はこちらのサイトを参考にさせていただきました。

Core Bluetooth with Swift (ObjCのおまけ付き) - Qiita

開発環境

  • Mac Book 12.1
  • Unity 2020.3.24f1
  • Xcode 13.2.1
  • iPhone8 14.8.1

各種環境をiOSのバージョンに合わせるためアップデートしました。

iOSプラグイン

Objective-CのコードはUnity側と連携するBluetoothLE.mmと、BLE周りの処理を実装するシングルトンクラスのBluetoothLECentral.mに分けています。

手元用なのでエラーハンドリングは甘め。

コード部分

BluetoothLE.mm

#import "BluetoothLECentral.h"

extern "C"
{
    void initialize()
    {
        [[BluetoothLECentral instance] initialize];
    }

    void startScan()
    {
        [[BluetoothLECentral instance] startScan];
    }

    void stopScan()
    {
        [[BluetoothLECentral instance] stopScan];
    }

    void connectToDevice(const char *address)
    {
        NSString *addressStr = [NSString stringWithCString:address encoding:NSUTF8StringEncoding];
        
        [[BluetoothLECentral instance] connectToDevice:addressStr];
    }

    void disconnectDevice()
    {
    }

    void discoverServices()
    {
        [[BluetoothLECentral instance] discoverServices];
    }

    void requestNotification(const char *serviceUUID, const char *charcteristicUUID)
    {
        NSString *serviceUUIDStr = [NSString stringWithCString:serviceUUID encoding:NSUTF8StringEncoding];
        
        NSString *charcteristicUUIDStr = [NSString stringWithCString:charcteristicUUID encoding:NSUTF8StringEncoding];
        
        [[BluetoothLECentral instance] requestNotification:serviceUUIDStr charcteristicUUID:charcteristicUUIDStr];
    }

    void sendMessage(const char *serviceUUID, const char *charcteristicUUID, const char *message)
    {
        NSString *serviceUUIDStr = [NSString stringWithCString:serviceUUID encoding:NSUTF8StringEncoding];
        
        NSString *charcteristicUUIDStr = [NSString stringWithCString:charcteristicUUID encoding:NSUTF8StringEncoding];
        
        NSString *messageStr = [NSString stringWithCString:message encoding:NSUTF8StringEncoding];
        
        [[BluetoothLECentral instance] sendMessage:serviceUUIDStr charcteristicUUID:charcteristicUUIDStr message:messageStr];
    }
}

BluetoothLECentral.h

#import <CoreBluetooth/CoreBluetooth.h>

@interface BluetoothLECentral : NSObject<CBCentralManagerDelegate, CBPeripheralDelegate>

@property (nonatomic, strong) CBCentralManager *centralManager;
@property (nonatomic, strong) CBPeripheral *peripheral;

+ (BluetoothLECentral *)instance;
- (void)initialize;
- (void)startScan;
- (void)stopScan;
- (void)connectToDevice:(NSString *)address;
- (void)discoverServices;
- (void)requestNotification:(NSString *)serviceUUID charcteristicUUID:(NSString *)charcteristicUUID;
- (void)sendMessage:(NSString *)serviceUUID charcteristicUUID:(NSString *)charcteristicUUID message:(NSString *)message;
- (void)centralManagerDidUpdateState:(CBCentralManager *)central;
@end

BluetoothLECentral.m

#import "UnityInterface.h"
#import "BluetoothLECentral.h"

@implementation BluetoothLECentral

static BluetoothLECentral *instance = nil;

// 検出したperipheralは接続のため保持.
NSMutableDictionary *peripheralDict = nil;

// MEMO:シングルトンにしないと[XPC connection invalid]が出てスキャン開始できない.
+ (BluetoothLECentral *)instance
{
    if (!instance)
    {
        instance = [BluetoothLECentral new];
    }
    return instance;
}

- (void)initialize
{
    self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil];
    
    peripheralDict = [NSMutableDictionary dictionary];
}

- (void)startScan
{
    [peripheralDict removeAllObjects];
    
    // スキャン開始.
    [self.centralManager scanForPeripheralsWithServices:nil options:nil];
}

- (void)stopScan
{
    // スキャン停止.
    [self.centralManager stopScan];
}

- (void)connectToDevice:(NSString *)address
{
    // デバイスに接続.
    CBPeripheral *peripheral = [peripheralDict objectForKey:address];
    [self.centralManager connectPeripheral:peripheral options:nil];
}

- (void)discoverServices
{
    //サービス探索開始.
    self.peripheral.delegate = self;
    [self.peripheral discoverServices:nil];
}

- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral
{
    self.peripheral = peripheral;
    
    // 接続成功通知.
    UnitySendMessage("BluetoothLEReceiver", "PluginMessage", "ConnectCallback");
}

- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error
{
    // 切断通知.
    UnitySendMessage("BluetoothLEReceiver", "PluginMessage", "DisconnectCallback");
}

- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary *)advertisementData RSSI:(NSNumber *)RSSI
{
    NSString *str = [NSString stringWithFormat:@"ScanCallback,%@,%@", peripheral.name,  peripheral.identifier.description];
    
    // スキャンしたperipheralを保持.
    [peripheralDict setObject:peripheral forKey:peripheral.identifier.description];
    
    // スキャンしたデバイス名とアドレスを通知.
    UnitySendMessage("BluetoothLEReceiver", "PluginMessage", [str UTF8String]);
}

- (void)centralManagerDidUpdateState:(CBCentralManager *)central
{
    switch (central.state)
    {
        case CBManagerStatePoweredOn:
            // BluetoothLEが有効になったら初期化完了.
            UnitySendMessage("BluetoothLEReceiver", "PluginMessage", "InitializeCallback");
            break;
            
        default:
            break;
    }
}

- (void) peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error
{
    for (int i = 0; i < peripheral.services.count; i++)
    {
       [peripheral discoverCharacteristics:nil forService:peripheral.services[i]];
    }
}

- (void) peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(NSError *)error
{
    for (CBCharacteristic *characteristic in service.characteristics)
    {
        NSString *str = [NSString stringWithFormat:@"DiscovereCharacteristicCallback,%@,%@", service.UUID.description, characteristic.UUID.description];
        UnitySendMessage("BluetoothLEReceiver", "PluginMessage", [str UTF8String]);
    }
}

- (void)requestNotification:(NSString *)serviceUUID charcteristicUUID:(NSString *)charcteristicUUID
{
    for (int i = 0; i < self.peripheral.services.count; i++)
    {
        CBService *service = self.peripheral.services[i];
        
        for (int j = 0; j < service.characteristics.count; j++)
        {
            if([serviceUUID isEqualToString:service.UUID.description] && [charcteristicUUID isEqualToString:service.characteristics[j].UUID.description])
            {
                [self.peripheral setNotifyValue:YES forCharacteristic:service.characteristics[j]];
            }
        }
    }
}

- (void)sendMessage:(NSString *)serviceUUID charcteristicUUID:(NSString *)charcteristicUUID message:(NSString *)message
{
    NSData *data = [message dataUsingEncoding:NSUTF8StringEncoding];
    
    for (int i = 0; i < self.peripheral.services.count; i++)
    {
        CBService *service = self.peripheral.services[i];
        
        for (int j = 0; j < service.characteristics.count; j++)
        {
            if([serviceUUID isEqualToString:service.UUID.description] && [charcteristicUUID isEqualToString:service.characteristics[j].UUID.description])
            {
                [self.peripheral writeValue:data forCharacteristic:service.characteristics[j] type:CBCharacteristicWriteWithResponse];
            }
        }
    }
}
@end

Unity

C#のコードは、アプリを制御するSpeedController.cs、プラグインに命令を送るBluetoothLE.cs、プラグインからメッセージを受け取るBluetoothLEReceiver.csの3つ。

BluetoothLE.csとBluetoothLEReceiver.csは以前実装したAndroid用プラグインを拡張する形にしています。

コード部分

SpeedController.cs

using UnityEngine;
using UnityEngine.UI;

public class SpeedController : MonoBehaviour
{
    [SerializeField]
    private Text status;

    [SerializeField]
    private Text speed;

    [SerializeField]
    private Slider slider;

    private const string DEVICE_NAME = "PR_BLE_02";
    
    // 太陽誘電の非公開な情報みたいなので隠します
    private const string SERVICE_UUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
    private const string NOTIFICATION_CHARACTERISTIC_UUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
    private const string WRITE_CHARACTERISTIC_UUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";

    private const float SEND_INTERVAL = 0.5f;

    private bool connected;
    private int lastSendValue;
    private int sendValue;
    private float time;

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

        slider.onValueChanged.AddListener(OnChangeValue);
    }

    void OnChangeValue(float value)
    {
        sendValue = value < 1f ? 0 : 64 + (int)((value - 1f) / 99f * 191f);
        speed.text = Mathf.FloorToInt(value / 100f * 120f).ToString() + "km/h";
    }

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

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

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

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

        status.text = "Scanning";
        status.color = Color.red;
        connected = false;

        slider.value = slider.minValue;
    }

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

            status.text = "Connected";
            status.color = Color.green;
            connected = true;
            time = 0f;
        }
    }

    private void Update()
    {
        if (connected)
        {
            time += Time.deltaTime;
            if (SEND_INTERVAL <= time)
            {
                time -= SEND_INTERVAL;
                if (lastSendValue != sendValue)
                {
                    BluetoothLE.SendMessage(SERVICE_UUID, WRITE_CHARACTERISTIC_UUID, sendValue + "\r\n");
                    lastSendValue = sendValue;
                }
            }
        }
    }

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

BluetoothLE.cs

using System;
using System.Runtime.InteropServices;
using UnityEngine;

public static class BluetoothLE
{
#if UNITY_EDITOR
#elif UNITY_ANDROID
    private static AndroidJavaObject androidBle;
#elif UNITY_IOS
    [DllImport("__Internal")]
    private static extern void initialize();
    
    [DllImport("__Internal")]
    private static extern void startScan();
    
    [DllImport("__Internal")]
    private static extern void stopScan();
    
    [DllImport("__Internal")]
    private static extern void connectToDevice(string address);
    
    [DllImport("__Internal")]
    private static extern void disconnectDevice();
    
    [DllImport("__Internal")]
    private static extern void discoverServices();
    
    [DllImport("__Internal")]
    private static extern bool sendMessage(string serviceUUID, string charcteristicUUID, string message);
    
    [DllImport("__Internal")]
    private static extern void requestNotification(string serviceUUID, string charcteristicUUID);
#endif

    private static BluetoothLEReceiver receiver;

    static BluetoothLE()
    {
        receiver = BluetoothLEReceiver.GetOrCreateReceiver();

#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle = new AndroidJavaObject("unity.android.plugin.BluetoothLE");
#elif UNITY_IOS
#endif
    }

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

#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("initialize");
#elif UNITY_IOS
        initialize();
#endif
    }

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

#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("startScan");
#elif UNITY_IOS
        startScan();
#endif
    }

    public static void StopScan()
    {
#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("stopScan");
#elif UNITY_IOS
        stopScan();
#endif
    }

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

#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("connectToDevice", address);
#elif UNITY_IOS
        connectToDevice(address);
#endif
    }

    public static void Disconnect()
    {
#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("disconnectDevice");
#elif UNITY_IOS
        disconnectDevice();
#endif
    }

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

#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("discoverServices");
#elif UNITY_IOS
        discoverServices();
#endif
    }

    public static void SendMessage(string serviceUUID, string charcteristicUUID, string message)
    {
#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("sendMessage", serviceUUID, charcteristicUUID, message);
#elif UNITY_IOS
        sendMessage(serviceUUID, charcteristicUUID, message);
#endif
    }

    public static void RequestNotification(string serviceUUID, string charcteristicUUID)
    {
#if UNITY_EDITOR
#elif UNITY_ANDROID
        androidBle.Call("requestNotification", serviceUUID, charcteristicUUID);
#elif UNITY_IOS
        requestNotification(serviceUUID, charcteristicUUID);
#endif
    }
}

BluetoothLEReceiver.cs

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":
                OnDisconnect?.Invoke();
                break;

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

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

Framework追加

iOSでBluetoothを使うにはCore Bluetooth Frameworkの追加が必要になります。

最近のUnityは便利になっていて、ビルド時のPostプロセスを拡張せずとも、作成したプラグインのinspector上から設定することができます。 f:id:nagomi0132:20220221212951p:plain

パーミッション追加

iOS13からBluetoothを使うためにパーミッションの確認が必要になったようです。

Unityをビルドして生成されるXcodeのプロジェクトにある、Info.plistに[Privacy – Bluetooth Always Usage Description]を追加します。

Xcodeを吐き出すたびに手動で設定しても良いのですが、何度もビルドすると面倒なので、Editorフォルダ以下にPostProcessBuildで自動的に設定する処理を追加すると便利です。

PostBuildProcess.cs

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
using System.IO;

public class PostBuildProcess
{
    [PostProcessBuild]
    public static void OnPostProcessBuild(BuildTarget buildTarget, string path)
    {
#if UNITY_IOS
        string plistPath = Path.Combine(path, "Info.plist");
        PlistDocument plist = new PlistDocument();
        plist.ReadFromFile(plistPath);

        // Bluetoothのパーミッション追加.
        PlistElementDict rootDict = plist.root;
        plist.root.SetString("NSBluetoothAlwaysUsageDescription", "");

        File.WriteAllText(plistPath, plist.WriteToString());
#endif
    }
}

動作テスト

youtu.be

無事に動きました。

Androidよりも送信間隔を長めにとらないと詰まってしまうみたいなので注意が必要です。

0.2秒間隔の送信だと徐々に遅延していったため、0.5秒間隔に変更して値の変更がない場合は送信をスキップするようにしました。

さいごに

これで最初にやりたかったことは一通り実現できました。

やっぱり自分の手で動くものを作れるのは楽しいです。

次は何をやろうか迷ってるとこですが、温湿度センサーと光学式の3Dプリンタを買ってみたので、それぞれサンプルから試してみようと思います。