かわいくて楽しそうなRing:bit Car v2を購入しました。
https://akiba-pc.watch.impress.co.jp/docs/news/news/1244961.html
360度回転サーボが2つ付いていて、筐体はアクリルです。これは犬?猫?
そして心臓部は、micro:bitですが、ちょうどLEDマトリクスが顔の部分になります。
これをPCのブラウザからBLEで接続して、PCにUSB接続したSony DUALSHOCK4から動かしてみようと思います。
ついでに、micro:bitには加速度センサがついているので、それもリアルタイムに表示させます。
今まで蓄積してきた知識が役に立ちそうです。
micro:bitをSensorTagとしてブラウザから使う:with Chromeブラウザ
戦車ラジコン:ブラウザ+GamePadで動かす
ソース一式は以下に置いておきました。
https://github.com/poruruba/ringbitcar
Webページとしても公開しておきました。
https://poruruba.github.io/ringbitcar/
micro:bitコーディング
MakeCodeを使います。
Microsoft MakeCode for micro:bit
https://makecode.microbit.org/
まず、拡張機能として以下の2つを使います。
・bluetooth
・servo
bluetoothを使いますので、デフォルトで入っている無線は使えなくなります。
また、拡張機能に、「ringbitcar」というまさにそのものがあるのですが、残念ながら、なぜかbluetoothと共存できないようで、使うのを諦めました。ですが、処理内容は単純で、サーボを操作しているだけなので、拡張機能servoで代用できます。
また、プロジェクトの設定で、「JustWorks pairing (default): Pairing is automatic once the pairing is initiated.」ではなく、「No Pairing Required: Anyone can connect via Bluetooth.」の方を有効にしておいてください。
作成したブロックは、全体でこんな感じなんですが、Javascriptで表示した方がわかりやすいですね。
function processCommand () {
if (cmd == "r" || cmd == "f") {
value = parseInt(param)
if (right != value) {
right = value
servos.P1.run(right)
}
}
if (cmd == "l" || cmd == "f") {
value = parseInt(param)
if (left != value) {
left = value
servos.P2.run(0 - left)
}
}
if (cmd == "t") {
value = parseInt(param)
servos.P1.run(50)
servos.P2.run(50)
basic.pause(value * 6)
servos.P1.run(0)
servos.P2.run(0)
right = 0
left = 0
}
else if (cmd == "T") {
value = parseInt(param)
servos.P1.run(-50)
servos.P2.run(-50)
basic.pause(value * 6)
servos.P1.run(0)
servos.P2.run(0)
right = 0
left = 0
}
else if (cmd == "s") {
value = parseInt(param)
servos.P1.run(50)
servos.P2.run(-50)
basic.pause(value * 143)
servos.P1.run(0)
servos.P2.run(0)
right = 0
left = 0
}
else if (cmd == "S") {
value = parseInt(param)
servos.P1.run(-50)
servos.P2.run(50)
basic.pause(value * 143)
servos.P1.run(0)
servos.P2.run(0)
right = 0
left = 0
}
else if (cmd == "i") {
value = parseInt(param)
if (value == 1) {
basic.showIcon(IconNames.Heart)
} else if (value == 2) {
basic.showIcon(IconNames.EigthNote)
} else if (value == 3) {
basic.showIcon(IconNames.No)
} else if (value == 4) {
basic.showLeds(`
. # # # .
# . . . #
# . . . #
# . . . #
. # # # .
`)
} else {
basic.showIcon(IconNames.Happy)
}
}
}
bluetooth.onBluetoothConnected(function () {
basic.showIcon(IconNames.Happy)
})
bluetooth.onBluetoothDisconnected(function () {
right = 0
servos.P1.run(right)
left = 0
servos.P2.run(left)
basic.showIcon(IconNames.Asleep)
})
bluetooth.onUartDataReceived(serial.delimiters(Delimiters.Fullstop), function () {
parseString(bluetooth.uartReadUntil(serial.delimiters(Delimiters.Fullstop)))
processCommand()
})
function parseString (text: string) {
cmd = text.charAt(0)
param = text.substr(1, text.length)
}
let left = 0
let right = 0
let param = ""
let value = 0
let cmd = ""
bluetooth.startAccelerometerService()
bluetooth.startUartService()
basic.showIcon(IconNames.Yes)
左右の車輪にサーボがつながっており、それが、P1ピンとP2ピンに接続されています。
サーボは、「普通のサーボモーター」と「回転サーボモーター」の2種類がありますが、後者の方です。
車輪の操作は上記の通りなのですが、次は、ブラウザとのBLE通信の設定です。
ブラウザは、BLEでの仮想UARTを使うのですが、単に、キャラクタリスティックに文字列を渡すだけです。
<最初だけ>
Bluetooth加速度計サービスとBluetooth UARTサービスを使います。
<Bluetooth接続されたとき>
接続されたことがわかるように、ドット絵をHappyにしています。
<Bluetooth接続が切断されたとき>
車輪の回転を止めて、寂しいドット絵にしています。
<Bluetoothデータを受信したとき>
これがまさにブラウザから受信するデータの処理部分です。
受信文字列を解析したのち、processCommand関数を呼び出しています。
<関数processCommand>
受信文字列のフォーマットとしては、こんな感じに定義しました。「.(ドット)」終わりにしています。
cmd(1文字)|param(任意長)|.
先頭1バイトで、ブラウザからの要求の種類を判別します。
paramには数字の文字列を指定します。
cmd | 処理内容 |
---|---|
r | 左側車輪の回転速度を設定します。paramは回転速度(-100~100)です。 |
l | 左側車輪の回転速度を設定します。paramは回転速度(-100~100)です。 |
f | 直進します。paramは回転速度(-100~100)です。 |
t | 車体を右旋回します。paramは回転角度(°)です。 |
T | 車体を左旋回します。paramは回転角度(°)です。 |
s | 前進します。paramは移動距離(cm)です。 |
S | 後退します。paramは移動距離(cm)です。 |
i | LEDマトリクスにドット絵を表示します。paramはドット絵の種類です。 |
回転速度の実際の速さは、サーボの能力に依存します。
回転角度や移動距離は、適当に決めたので、環境に合わせて微調整してください。
あとは、micro:bitをUSBケーブルで接続して、出てきたドライブにhexファイルを書き込むだけです。
書き込みが終わると、チェックマークのドット絵が表示されたかと思います。
#ブラウザ側
ブラウザでは、Web Bluetooth APIとHTML5 GamePad APIを利用します。
Bluetoothを使うので、HTTPSでホスティングしている必要があります。
HTML5 GamePad APIに対応していればなんでもよいので、Sony DUALSHOCK4である必要はありません。
まずは、HTMLファイルから。
こんな画面です。
非常にシンプルで、Ring:bitに接続するためのボタンと受信した加速度の表示ぐらいです。
特に複雑なものはありません。Bootstrap(v3.4.1)とVue v2を使っています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<!-- Optional theme -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
<!-- Latest compiled and minified JavaScript -->
<script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>
<title>Ring:bit Car Controller</title>
<link rel="stylesheet" href="css/start.css">
<script src="js/methods_utils.js"></script>
<script src="js/vue_utils.js"></script>
<script src="dist/js/vconsole.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<div id="top" class="container">
<h1>Ring:bit Car Controller</h1>
<button class="btn btn-primary" v-on:click="ring_connect">Connect</button>
<br>
<label>isConected</label> {{isConnected}}
<label>deviceName</label> {{deviceName}}
<br>
<h3>accelerometer</h3>
<label class="col-xs-1">X</label><p class="col-xs-1 text-right">{{accel_x}}</p>
<label class="col-xs-1">Y</label><p class="col-xs-1 text-right">{{accel_y}}</p>
<label class="col-xs-1">Z</label><p class="col-xs-1 text-right">{{accel_z}}</p>
<div class="modal fade" id="progress">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{progress_title}}</h4>
</div>
<div class="modal-body">
<center><progress max="100" /></center>
</div>
</div>
</div>
</div>
</div>
<script src="js/start.js"></script>
</body>
肝心のJavascriptです。
'use strict';
//var vConsole = new VConsole();
const UUID_SERVICE_ACCEL = "e95d0753-251d-470a-a062-fa1922dfa9a8";
const UUID_CHAR_ACCEL = "e95dca4b-251d-470a-a062-fa1922dfa9a8";
const UUID_SERVICE_UART = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
const UUID_CHAR_UART_RX = '6e400002-b5a3-f393-e0a9-e50e24dcca9e';
const UUID_CHAR_UART_TX = '6e400003-b5a3-f393-e0a9-e50e24dcca9e';
const POWER_MAX = 50;
const CHECK_INTERVAL = 50;
var vue_options = {
el: "#top",
data: {
progress_title: '',
characteristics : new Map(),
deviceName: '',
isConnected : false,
encoder : new TextEncoder('utf-8'),
control_pressed : { top: false, down: false, left: false, right: false, a: false, b: false, x: false, y: false },
power_prev: { right: 0, left: 0 },
accel_x: 0,
accel_y: 0,
accel_z: 0,
},
computed: {
},
methods: {
ring_connect: async function(){
var device = await navigator.bluetooth.requestDevice({
filters: [{
namePrefix: "BBC micro:bit"
}],
optionalServices: [
UUID_SERVICE_UART,
UUID_SERVICE_ACCEL,
]
});
console.log("requestDevice OK");
this.characteristics.clear();
this.bluetoothDevice = device;
this.bluetoothDevice.addEventListener('gattserverdisconnected', (event) => {
this.onDisconnect(event)
});
var server = await this.bluetoothDevice.gatt.connect();
console.log('Execute : getPrimaryService');
var service = await server.getPrimaryService(UUID_SERVICE_UART);
console.log('Execute : getCharacteristic');
await this.setCharacteristic(service, UUID_CHAR_UART_TX);
await this.setCharacteristic(service, UUID_CHAR_UART_RX);
await this.startNotify(UUID_CHAR_UART_RX);
var service = await server.getPrimaryService(UUID_SERVICE_ACCEL);
console.log('Execute : getCharacteristic');
await this.setCharacteristic(service, UUID_CHAR_ACCEL);
await this.startNotify(UUID_CHAR_ACCEL);
this.deviceName = this.bluetoothDevice.name;
this.isConnected = true;
this.ring_start();
},
onDisconnect: function(event){
console.log('onDisconnect');
this.isConnected = false;
this.characteristics.clear();
},
startNotify(uuid) {
if( this.characteristics.get(uuid) === undefined )
throw "Not Connected";
console.log('Execute : startNotifications');
return this.characteristics.get(uuid).startNotifications();
},
async setCharacteristic(service, characteristicUuid) {
var characteristic = await service.getCharacteristic(characteristicUuid)
console.log('setCharacteristic : ' + characteristicUuid);
this.characteristics.set(characteristicUuid, characteristic);
var _this_ = this;
characteristic.addEventListener('characteristicvaluechanged', function(event){ _this_.onDataChanged(event); });
// characteristic.addEventListener('characteristicvaluechanged', this.onDataChanged);
return service;
},
writeChar(uuid, array_value) {
if( this.characteristics.get(uuid) === undefined )
throw "Not Connected";
// console.log('Execute : writeValue');
let data = Uint8Array.from(array_value);
return this.characteristics.get(uuid).writeValue(data);
},
onDataChanged(event){
// console.log('onDataChanged');
let characteristic = event.target;
var value = characteristic.value;
switch(characteristic.uuid){
case UUID_CHAR_ACCEL: {
this.accel_x = value.getInt16(0, true);
this.accel_y = value.getInt16(2, true);
this.accel_z = value.getInt16(4, true);
break;
}
}
},
ring_start: async function(){
await this.check_gamepad();
},
check_gamepad: async function(){
var gamepadList = navigator.getGamepads();
for(var i = 0; i < gamepadList.length; i++){
var gamepad = gamepadList[i];
if(gamepad){
var power_right = Math.floor(-gamepad.axes[1] * POWER_MAX);
if( this.power_prev.right != power_right ){
this.power_prev.right = power_right;
var cmd = 'r' + power_right + '.';
// console.log(cmd);
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode(cmd));
}
var power_left = Math.floor(-gamepad.axes[3] * POWER_MAX);
if( this.power_prev.left != power_left ){
this.power_prev.left = power_left;
var cmd = 'l' + power_left + '.';
// console.log(cmd);
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode(cmd));
}
if( gamepad.buttons[0].pressed ){
if( !this.control_pressed.a ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i3.'));
this.control_pressed.a = true;
}
}else{
if( this.control_pressed.a ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i0.'));
this.control_pressed.a = false;
}
}
if( gamepad.buttons[1].pressed ){
if( !this.control_pressed.b ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i4.'));
this.control_pressed.b = true;
}
}else{
if( this.control_pressed.b ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i0.'));
this.control_pressed.b = false;
}
}
if( gamepad.buttons[2].pressed ){
if( !this.control_pressed.x ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i1.'));
this.control_pressed.x = true;
}
}else{
if( this.control_pressed.x ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i0.'));
this.control_pressed.x = false;
}
}
if( gamepad.buttons[3].pressed ){
if( !this.control_pressed.y ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i2.'));
this.control_pressed.y = true;
}
}else{
if( this.control_pressed.y ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('i0.'));
this.control_pressed.y = false;
}
}
if( gamepad.buttons[12].pressed ){
if( !this.control_pressed.top ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('s5.'));
this.control_pressed.top = true;
}
}else{
this.control_pressed.top = false;
}
if( gamepad.buttons[13].pressed ){
if( !this.control_pressed.down ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('S5.'));
this.control_pressed.down = true;
}
}else{
this.control_pressed.down = false;
}
if( gamepad.buttons[14].pressed ){
if( !this.control_pressed.left ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('T90.'));
this.control_pressed.left = true;
}
}else{
this.control_pressed.left = false;
}
if( gamepad.buttons[15].pressed ){
if( !this.control_pressed.right ){
await this.writeChar(UUID_CHAR_UART_TX, this.encoder.encode('t90.'));
this.control_pressed.right = true;
}
}else{
this.control_pressed.right = false;
}
break;
}
}
setTimeout(this.check_gamepad, CHECK_INTERVAL);
}
},
created: function(){
},
mounted: function(){
proc_load();
setInterval
}
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );
〇関数ring_connect
Ring:bit CardとBLE接続します。
ここら辺はお決まりです。
各UUIDは以下の通りです。
・加速度センサのプライマリサービス: e95d0753-251d-470a-a062-fa1922dfa9a8
・加速度センサのキャラクタリスティック:e95dca4b-251d-470a-a062-fa1922dfa9a8
※キャラクタリスティックはNotificationです。
・仮想UARTのプライマリサービス: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
・仮想UARTのRXのキャラクタリスティック: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
・仮想UARTのTXのキャラクタリスティック: 6e400003-b5a3-f393-e0a9-e50e24dcca9e
※TXのキャラクタリスティックを使っており、RXは今回使っていません。
加速度センサからNotificationを受信するため、StartNotifyしています。
その最後に、ring_startを呼び出しています。
〇関数ring_start
check_gamepadを呼び出して、GamePadの状態を取得しています。
〇関数check_gamepad
やっていることは、PCに接続されているGamePadを探して、コントローラやボタン押下状態に応じた処理を行っています。
対象 | 処理内容 |
---|---|
左側アナログコントローラ | 左側車輪の加速 |
右側アナログコントローラ | 右側車輪の加速 |
左側十字キー | (上)前進、(下)後退、(左)左旋回、(右)右旋回 |
右側ボタン | 押している間LEDマトリクスのドット絵を変更 |
左右のアナログコントローラで、左右の車輪を独立で操作します。
戦車を操作する感覚です。具体的には、
左右両方を上に倒すと前に前進し、両方を下に倒すと後退します。
右より左が上であれば右に曲がり、左より右が上であれば左に曲がります。
CHECK_INTERVAL=50msecの間隔でチェックしているので、BLE通信過多になるかもしれず、前回チェック時と同じ値だったら送信しないようにしています。
〇コールバック関数onDataChanged
micro:bitからNotificationが受信されると、呼び出されます。
加速度センサーからのNotificationであれば、int16がリトルエンディアンで3つありますので、それぞれ取得しています。
#終わりに
すんなり実装できました。
BeetleCより断然楽でした。
以上