はじめに
Hiroshi Takagiと申します。普段は組み込みエンジニアをやっています。
toioで公開されている、技術仕様書とjavascriptライブラリを参考にしながら、Unityを使ってスマホアプリで動作するtoioの開発環境を作りました。
完成した。 pic.twitter.com/SNwwaIIcmn
— Hiroshi Takagi (@TkgHrsh) December 13, 2019
技術仕様ver2.0.0で公開されているものはほぼすべて操作可能なスクリプトもご用意したので、興味ある方はぜひ使ってみてください。
ただ、できるだけ素早く簡単にできることを目標にしたので、Bluetooth Low Energy(BLE)の有料アセットが必要になります。
Unityはつい最近始めたのですが、学習にはこの本がおすすめです。
Unityの教科書 Unity2019完全対応版
それではやっていきましょう。
使ったもの
- Unity (2018.4.14f1)
- toio コア キューブ 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方法が分からない方はこの記事を参考にするとよいかと思います。
キューブを操作するスクリプトの配置
以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。
コード全文
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;
}
}
このスクリプトは、自動的にキューブを探してペアリングして、完了すると各機能の操作および状態の通知が実行されるようになっています。
サンプルアプリケーションの作成
以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。
コード全文
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 = "角度情報なし";
}
});
}
}
今回のサンプルアプリケーションの構成です。
下図のように、空のObjectを二つ(CubeController, Director)とTextオブジェクト(Battery, Flat, Collision, Button, PositionID, StandardID, Angle, Tap)を8個を登録してください。
名前を間違えるとうまく動きませんのでご注意ください。Textの配置とサイズは適当にお願いします。
最後に、CubeControllerにCubeController.csを、DirectorにDirector.csをアタッチすれば準備完了です。
ビルドしてスマホに移す
この記事を参考にするとよいと思います。
iOSの話になってしまいますが、一点注意点です。
XCODEから実行する際に、下図のように、Infoに[Privacy - Bluetooth Always Usage Description]を追加する必要があります。これがないとアプリが起動できなくなります。
さいごに
うまく動きましたでしょうか?
あとはサンプルアプリケーションを参考に、自分のオリジナルのtoioアプリが作成できると思いますので、ぜひ使ってみてください。
最新のtoioの技術仕様書を見ると、ver2.1.0に更新されているので、またアップデートしてご紹介したいと思います。
それではよいクリスマスをお過ごしください。