Unityを使って手軽にスマホアプリからtoioを操作する方法（ライブラリ付き）


# はじめに

**Hiroshi Takagi**と申します。普段は組み込みエンジニアをやっています。

[toio](https://toio.io/)で公開されている、[技術仕様書](https://toio.github.io/toio-spec/docs/ble_sound)と[javascriptライブラリ](https://github.com/toio/toio.js)を参考にしながら、Unityを使ってスマホアプリで動作するtoioの開発環境を作りました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">完成した。 <a href="https://t.co/SNwwaIIcmn">pic.twitter.com/SNwwaIIcmn</a></p>&mdash; Hiroshi Takagi (@TkgHrsh) <a href="https://twitter.com/TkgHrsh/status/1205459393535332352?ref_src=twsrc%5Etfw">December 13, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

技術仕様ver2.0.0で公開されているものはほぼすべて操作可能なスクリプトもご用意したので、興味ある方はぜひ使ってみてください。

ただ、できるだけ素早く簡単にできることを目標にしたので、Bluetooth Low Energy(BLE)の[有料アセット](https://assetstore.unity.com/packages/tools/network/bluetooth-le-for-ios-tvos-and-android-26661)が必要になります。

Unityはつい最近始めたのですが、学習にはこの本がおすすめです。
[Unityの教科書 Unity2019完全対応版](https://www.sbcr.jp/product/4815602543/)

それではやっていきましょう。

# 使ったもの
* Unity (2018.4.14f1)
* [toio コア キューブ](https://toio.io/platform/) 1台
* Mac 1台 [Mac mini(2018) OS Version10.14.6]
* iPhone 1台 [iPhone 7]
* Xcode(11.3)

Windows,Androidではまだ試していませんので、もしやってみたかたいましたら、ご連絡いただけると嬉しいです。

# アセットのインポート

まずは以下のアセットを新規2Dプロジェクトにインポートします。
https://assetstore.unity.com/packages/tools/network/bluetooth-le-for-ios-tvos-and-android-26661

アセットのImport方法が分からない方は[この記事](https://tech-camp.in/note/technology/4432/)を参考にするとよいかと思います。

# キューブを操作するスクリプトの配置

以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。

<details><summary>コード全文</summary><div>

```cs:CubeController.cs
using System;
using UnityEngine;


public class CubeLightParams
{
    public uint red;
    public uint green;
    public uint blue;
    public UInt16 durationMs;
    public CubeLightParams(uint red, uint green, uint blue, UInt16 durationMs)
    {
        this.red = red;
        this.green = green;
        this.blue = blue;
        this.durationMs = durationMs;
    }
}

public class CubeSoundParams
{
    public uint noteNum;
    public UInt16 durationMs;
    public CubeSoundParams(uint noteNum, UInt16 durationMs)
    {
        this.noteNum = noteNum;
        this.durationMs = durationMs;
    }
}


public class CubeController : MonoBehaviour
{
    public enum States
    {
        None,
        Scan,
        ScanRSSI,
        Connect,
        Disconnect,
        Connecting,
    }

    private const string DeviceName = "toio Core Cube";
    private const string ServiceUUID = "10B20100-5B3B-4571-9508-CF3EFCD7BBAE";

    private const string IdCharacteristic = "10B20101-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string SensorCharacteristic = "10B20106-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string ButtonCharacteristic = "10B20107-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string BatteryCharacteristic = "10B20108-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string MotorCharacteristic = "10B20102-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string LightCharacteristic = "10B20103-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string SoundCharacteristic = "10B20104-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string ConfigrationCharacteristic = "10B201FF-5B3B-4571-9508-CF3EFCD7BBAE";

    private string[] _characteristics = {
        IdCharacteristic,
        SensorCharacteristic,
        ButtonCharacteristic,
        BatteryCharacteristic,
        MotorCharacteristic,
        LightCharacteristic,
        SoundCharacteristic,
        ConfigrationCharacteristic
    };

    private float _timeout = 0f;
    private States _state = States.None;
    private string _deviceAddress;
    private int _foundCharCount = 0;
    private bool _rssiOnly = false;
    private int _rssi = 0;

    void Reset()
    {
        _timeout = 0f;
        _state = States.None;
        _deviceAddress = null;
        _foundCharCount = 0;
        _rssi = 0;
    }

    void SetState(States newState, float timeout)
    {
        _state = newState;
        _timeout = timeout;
    }

    void StartProcess()
    {
        Reset();
        BluetoothLEHardwareInterface.Initialize(true, false, () =>
        {

            SetState(States.Scan, 0.1f);

        }, (error) =>
        {
            BluetoothLEHardwareInterface.Log("Error during initialize: " + error);
        });
    }

    // Use this for initialization
    void Start()
    {
        StartProcess();
    }

    // Update is called once per frame
    void Update()
    {
        if (_timeout > 0f)
        {
            _timeout -= Time.deltaTime;
            if (_timeout <= 0f)
            {
                _timeout = 0f;

                switch (_state)
                {
                    case States.None:
                        break;

                    case States.Scan:
                        BluetoothLEHardwareInterface.ScanForPeripheralsWithServices(null, (address, name) =>
                        {

                            // if your device does not advertise the rssi and manufacturer specific data
                            // then you must use this callback because the next callback only gets called
                            // if you have manufacturer specific data

                            if (!_rssiOnly)
                            {
                                if (name.Contains(DeviceName))
                                {
                                    BluetoothLEHardwareInterface.StopScan();

                                    // found a device with the name we want
                                    // this example does not deal with finding more than one
                                    _deviceAddress = address;
                                    SetState(States.Connect, 0.5f);
                                }
                            }

                        }, (address, name, rssi, bytes) =>
                        {

                            // use this one if the device responses with manufacturer specific data and the rssi

                            if (name.Contains(DeviceName))
                            {
                                if (_rssiOnly)
                                {
                                    _rssi = rssi;
                                }
                                else
                                {
                                    BluetoothLEHardwareInterface.StopScan();

                                    // found a device with the name we want
                                    // this example does not deal with finding more than one
                                    _deviceAddress = address;
                                    SetState(States.Connect, 0.5f);
                                }
                            }

                        }, _rssiOnly); // this last setting allows RFduino to send RSSI without having manufacturer data

                        if (_rssiOnly)
                            SetState(States.ScanRSSI, 0.5f);
                        break;

                    case States.ScanRSSI:
                        break;

                    case States.Connect:
                        // set these flags
                        _foundCharCount = 0;

                        // note that the first parameter is the address, not the name. I have not fixed this because
                        // of backwards compatiblity.
                        BluetoothLEHardwareInterface.ConnectToPeripheral(_deviceAddress, null, null, (address, serviceUUID, characteristicUUID) =>
                        {

                            if (IsEqual(serviceUUID, ServiceUUID))
                            {
                                for (int i = 0; i < this._characteristics.Length; i++)
                                {
                                    if (IsEqual(characteristicUUID, this._characteristics[i]))
                                    {
                                        this._foundCharCount++;
                                    }
                                }
                                // if we have found all characteristics that we are waiting for
                                // set the state. make sure there is enough timeout that if the
                                // device is still enumerating other characteristics it finishes
                                // before we try to subscribe
                                if (this._foundCharCount == this._characteristics.Length)
                                {
                                    SetState(States.Connecting, 0);
                                    batterySubscribe();
                                    motionSensorSubscribe();
                                    buttonSubscribe();
                                    idInformationSubscribe();
                                }
                            }
                        });
                        break;
                }
            }
        }
    }

    bool IsEqual(string uuid1, string uuid2)
    {
        return (uuid1.ToUpper().CompareTo(uuid2.ToUpper()) == 0);
    }

    //
    // Motor
    //

    public void Move(int left, int right, uint durationMs)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte leftDir = (byte)((left >= 0) ? 01 : 02);
        byte rightDir = (byte)((right >= 0) ? 01 : 02);
        byte leftVal = (byte)Math.Min(Math.Abs(left), 0xff);
        byte rightVal = (byte)Math.Min(Math.Abs(right), 0xff);
        byte dur = (byte)Math.Min(durationMs / 10, 0xff);

        byte[] data = new byte[] { 02, 01, leftDir, leftVal, 02, rightDir, rightVal, dur };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, MotorCharacteristic, data, data.Length, false, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    public void MoveStop()
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 01, 01, 01, 00, 02, 01, 00 };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, MotorCharacteristic, data, data.Length, false, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    //
    // Light
    //

    public void LightUp(CubeLightParams[] arr, uint repeat)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        if (arr.Length >= 30)
        {
            Debug.Log("too much array Length");
            return;
        }
        byte[] data = new byte[3 + 6 * arr.Length];
        int len = 0;

        data[len++] = 04;
        data[len++] = (byte)repeat;
        data[len++] = (byte)arr.Length;
        for (int i = 0; i < arr.Length; i++)
        {
            data[len++] = (byte)Math.Min(arr[i].durationMs / 10, 0xff);
            data[len++] = 01;
            data[len++] = 01;
            data[len++] = (byte)arr[i].red;
            data[len++] = (byte)arr[i].green;
            data[len++] = (byte)arr[i].blue;
        }

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, LightCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    public void LightOff()
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 01 };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, LightCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    //
    // Sound
    //

    public void Sound(CubeSoundParams[] arr, uint repeat)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        if (arr.Length >= 60)
        {
            Debug.Log("too much array Length");
            return;
        }
        byte[] data = new byte[3 + 3 * arr.Length];
        int len = 0;

        data[len++] = 03;
        data[len++] = (byte)repeat;
        data[len++] = (byte)arr.Length;
        for (int i = 0; i < arr.Length; i++)
        {
            data[len++] = (byte)Math.Min(arr[i].durationMs / 10, 0xff);
            data[len++] = (byte)arr[i].noteNum;
            data[len++] = 0xff;
        }

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });

    }

    public void SoundPreset(uint id)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 02, (byte)id, 0xff };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });

    }

    public void SoundOff()
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 01 };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });

    }

    //
    // Battery
    //

    private Action<uint> batteryCb = null;

    private void batterySubscribe()
    {
        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, BatteryCharacteristic, null, (address, characteristic, bytes) =>
        {
            if (this.batteryCb != null)
            {
                this.batteryCb(bytes[0]);
            }
        });
    }
    public void GetBattery(Action<uint> result)
    {
        this.batteryCb = result;
    }

    //
    // Motion Sensor
    //

    private bool lastCollisiton = false;
    private Action<bool, bool> motionSensorCb = null;

    private void motionSensorSubscribe()
    {
        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, SensorCharacteristic, null, (address, characteristic, bytes) =>
        {
            Debug.Log("motion sensro changed");
            if (this.motionSensorCb != null)
            {
                if (bytes[0] == 01)
                {
                    bool flat = (bytes[1] == 01);
                    bool collisiton = (bytes[2] == 01);
                    this.motionSensorCb(flat, collisiton);
                    this.lastCollisiton = collisiton;
                }
            }
        });
    }

    public void GetMotionSensor(Action<bool, bool> result)
    {
        this.motionSensorCb = result;
    }

    //
    // Button
    //

    private Action<bool> buttonCb = null;

    private void buttonSubscribe()
    {
        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, ButtonCharacteristic, null, (address, characteristic, bytes) =>
        {
            if (this.buttonCb != null)
            {
                if (bytes[0] == 01)
                {
                    this.buttonCb(bytes[1] == 0x80);
                }
            }
        });
    }
    public void GetButton(Action<bool> result)
    {
        this.buttonCb = result;
    }

    //
    // ID Information
    //

    private Action<UInt16, UInt16, UInt32, UInt16> idInformationCb = null;

    private void idInformationSubscribe()
    {
        UInt16 positionX = 0xffff;
        UInt16 positionY = 0xffff;
        UInt16 angle = 0xffff;
        UInt32 standardID = 0xffffffff;

        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, IdCharacteristic, null, (address, characteristic, bytes) =>
        {
            if (this.idInformationCb != null)
            {
                switch (bytes[0])
                {
                    case 01:
                        positionX = (UInt16)(bytes[1] | (bytes[2] << 8));
                        positionY = (UInt16)(bytes[3] | (bytes[4] << 8));
                        standardID = 0xffffffff;
                        angle = (UInt16)(bytes[5] | (bytes[6] << 8));
                        this.idInformationCb(positionX, positionY, standardID, angle);
                        break;
                    case 02:
                        positionX = 0xffff;
                        positionY = 0xffff;
                        standardID = (UInt32)(bytes[1] | (bytes[2] << 8) | (bytes[3] << 16) | (bytes[4] << 24));
                        angle = (UInt16)(bytes[5] | (bytes[6] << 8));
                        this.idInformationCb(positionX, positionY, standardID, angle);
                        break;
                    case 03:
                    case 04:
                        positionX = 0xffff;
                        positionY = 0xffff;
                        standardID = 0xffffffff;
                        angle = 0xffff;
                        this.idInformationCb(positionX, positionY, standardID, angle);
                        break;
                }
            }
        });
    }

    public void GetIdInformation(Action<UInt16, UInt16, UInt32, UInt16> result)
    {
        this.idInformationCb = result;
    }


}
```
</div></details>

このスクリプトは、自動的にキューブを探してペアリングして、完了すると各機能の操作および状態の通知が実行されるようになっています。

# サンプルアプリケーションの作成

以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。

<details><summary>コード全文</summary><div>

```cs:Director.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Director : MonoBehaviour
{
    GameObject Battery;
    GameObject Flat;
    GameObject Collision;
    GameObject CubeController;
    GameObject Button;
    GameObject PositionID;
    GameObject StandardID;
    GameObject Angle;

    GameObject Tap;

    bool enMove = false;

    // Start is called before the first frame update
    void Start()
    {
        this.Battery = GameObject.Find("Battery");
        this.Flat = GameObject.Find("Flat");
        this.Collision = GameObject.Find("Collision");
        this.CubeController = GameObject.Find("CubeController");
        this.Button = GameObject.Find("Button");
        this.PositionID = GameObject.Find("PositionID");
        this.StandardID = GameObject.Find("StandardID");
        this.Angle = GameObject.Find("Angle");
        this.Tap = GameObject.Find("Tap");

        notifyStatus();
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            if (enMove)
            {
                stopMove();
            }
            else
            {
                startMove();
            }
            enMove = !enMove;
        }
    }

    private void startMove()
    {
        CubeSoundParams[] sound = new CubeSoundParams[4];
        sound[0] = new CubeSoundParams(60, 200);
        sound[1] = new CubeSoundParams(72, 200);
        sound[2] = new CubeSoundParams(84, 200);
        sound[3] = new CubeSoundParams(128, 1000);

        CubeLightParams[] light = new CubeLightParams[4];
        light[0] = new CubeLightParams(255, 255, 0, 400);
        light[1] = new CubeLightParams(0, 255, 255, 400);
        light[2] = new CubeLightParams(255, 0, 255, 400);
        light[3] = new CubeLightParams(255, 255, 255, 400);

        this.CubeController.GetComponent<CubeController>().Sound(sound, 0);
        this.CubeController.GetComponent<CubeController>().LightUp(light, 0);
        this.CubeController.GetComponent<CubeController>().Move(20, 20, 0);
        this.Tap.GetComponent<Text>().text = "Tap to stop moving";
    }
    private void stopMove()
    {
        this.CubeController.GetComponent<CubeController>().SoundOff();
        this.CubeController.GetComponent<CubeController>().LightOff();
        this.CubeController.GetComponent<CubeController>().MoveStop();
        this.Tap.GetComponent<Text>().text = "Tap to start moving";
    }

    private void notifyStatus()
    {
        this.CubeController.GetComponent<CubeController>().GetBattery((result) =>
        {
            this.Battery.GetComponent<Text>().text = "Battery残量 : " + result.ToString("D3") + "%";
        });
        this.CubeController.GetComponent<CubeController>().GetMotionSensor((isFlat, isCollision) =>
        {
            if (isFlat)
            {
                this.Flat.GetComponent<Text>().text = "水平検出 : 水平";
            }
            else
            {
                this.Flat.GetComponent<Text>().text = "水平検出 : 水平でない";
            }
            if (isCollision)
            {
                this.Collision.GetComponent<Text>().text = "衝突検出 : 衝突あり";
            }
            else
            {
                this.Collision.GetComponent<Text>().text = "衝突検出 : 衝突なし";
            }
        });
        this.CubeController.GetComponent<CubeController>().GetButton((result) =>
        {
            if (result)
            {
                this.Button.GetComponent<Text>().text = "ボタン : ON";
            }
            else
            {
                this.Button.GetComponent<Text>().text = "ボタン : OFF";
            }
        });
        this.CubeController.GetComponent<CubeController>().GetIdInformation((positionX, positionY, standardID, angle) =>
        {
            if (positionX != 0xffff || positionY != 0xffff)
            {
                this.PositionID.GetComponent<Text>().text = "X座標 : " + positionX.ToString("D3") + " / Y座標 : " + positionY.ToString("D3");
            }
            else
            {
                this.PositionID.GetComponent<Text>().text = "Position ID情報なし";
            }
            if (standardID != 0xffffffff)
            {
                this.StandardID.GetComponent<Text>().text = "Standard Id : " + standardID.ToString("D7");
            }
            else
            {
                this.StandardID.GetComponent<Text>().text = "Standard ID情報なし";
            }
            if (angle != 0xffff)
            {
                this.Angle.GetComponent<Text>().text = "角度 : " + angle.ToString("D3");
            }
            else
            {
                this.Angle.GetComponent<Text>().text = "角度情報なし";
            }
        });

    }
}

```
</div></details>

今回のサンプルアプリケーションの構成です。

下図のように、空のObjectを二つ（CubeController, Director）とTextオブジェクト(Battery, Flat, Collision, Button, PositionID, StandardID, Angle, Tap)を8個を登録してください。
名前を間違えるとうまく動きませんのでご注意ください。Textの配置とサイズは適当にお願いします。

最後に、CubeControllerにCubeController.csを、DirectorにDirector.csをアタッチすれば準備完了です。

試しにPC上で、実行して動作するか確認してみてください。
<img width="1552" alt="スクリーンショット 2019-12-13 午後4.28.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542743/68c2e040-5a75-f4ce-4710-048170fc4837.png">

# ビルドしてスマホに移す

[この記事](https://vracademy.jp/vrunner/2017/09/build-tutorial1/)を参考にするとよいと思います。

iOSの話になってしまいますが、一点注意点です。
XCODEから実行する際に、下図のように、Infoに[Privacy - Bluetooth Always Usage Description]を追加する必要があります。これがないとアプリが起動できなくなります。
![スクリーンショット 2019-12-11 0.45.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542743/9ddefdbd-ce2a-7529-37d7-ebb0e5199019.png)

# さいごに
うまく動きましたでしょうか？
あとはサンプルアプリケーションを参考に、自分のオリジナルのtoioアプリが作成できると思いますので、ぜひ使ってみてください。

最新のtoioの[技術仕様書](https://toio.github.io/toio-spec/docs/ble_sound)を見ると、ver2.1.0に更新されているので、またアップデートしてご紹介したいと思います。

それではよいクリスマスをお過ごしください。