デモ動画
部品
- obniz Board 1Y
- BLEに対応したAndroid機器
動作の仕組み
obniz側
- Android機器とBLEで接続
- 一定時間ごとにキャラクタリスティクスの値の更新通知を出す
Android側
- obnizとBLEで接続
- キャラクタリスティクスの更新通知を購読する
- 更新通知を受ければRSSI(電波強度)の値を読む
- RSSIの値に応じて音の周波数を変える
ソースコード
obniz側
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
/>
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script
src="https://unpkg.com/obniz@3.x/obniz.js"
crossorigin="anonymous"
></script>
</head>
<body>
<div id="obniz-debug"></div>
<script>
var obniz = new Obniz("OBNIZ_ID_HERE");
// called on online
obniz.onconnect = async function () {
// 画面にタイトルを表示
obniz.display.print("BLE Theremin");
// BLEを初期化
await obniz.ble.initWait();
obniz.ble.peripheral.onconnectionupdates = function (data) {
if (data.status === "connected") {
console.log("connected from remote device ", data.address);
} else if (data.status === "disconnected") {
console.log("disconnected from remote device ", data.address);
}
};
// サービス・キャラクタリスティック登録
const service = new obniz.ble.service({ uuid: "1063" });
const characteristic = new obniz.ble.characteristic({
uuid: "7777",
data: [1, 2, 3], // 適当な値
properties: ["read", "notify"],
descriptors: [
{
uuid: "2902", //CCCD
data: [0x00, 0x00], //2byte
},
],
});
service.addCharacteristic(characteristic);
obniz.ble.peripheral.addService(service);
// advertisementのデータを設定
obniz.ble.advertisement.setAdvData(service.advData);
obniz.ble.advertisement.setScanRespData({
localName: "BLE Theremin",
});
// advertisement送信開始
obniz.ble.advertisement.start();
obniz.display.print("Advertising...");
// 250ms毎に更新通知
setInterval(async () => {
await characteristic.writeWait([0xff]);
characteristic.notify();
console.log("notify");
}, 250);
};
</script>
</body>
</html>
Android機器側
メインアクティビティ
動作に必要な権限の取得処理は省略しています.
MainActivity.kt
package dev.rioil.ble_theremin
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioTrack
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors
import kotlin.collections.ArrayList
import kotlin.collections.HashSet
import kotlin.concurrent.thread
class MainActivity : AppCompatActivity() {
companion object {
const val REQUEST_ENABLE_BT = 1
const val OBNIZ_DEVICE_NAME = "BLE Theremin"
}
private val executorService = Executors.newFixedThreadPool(4)
private var bluetoothGatt: BluetoothGatt? = null
val bleDevices = HashSet<BluetoothDevice>()
val soundGenerator = SoundGenerator(44100);
val receivedRssis = ConcurrentLinkedQueue<RssiData>()
var bleScanner: BluetoothLeScanner? = null
var bleScanCallback: ScanCallback? = null
var audioTrack: AudioTrack? = null
@Volatile
var isStopRequested: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnScan.setOnClickListener {
scanObniz()
}
btnConnect.setOnClickListener {
startPlay()
}
btnStop.setOnClickListener {
stop()
}
audioTrack = soundGenerator.audioTrack
}
/**
* Obnizのスキャンを行います
*/
private fun scanObniz() {
val bluetoothAdapter: BluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
?: return // デバイスがBluetoothをサポートしていないときは終了
// Bluetoothが有効でなければダイアログを出して有効化
if (!bluetoothAdapter.isEnabled) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}
bleScanner = bluetoothAdapter.bluetoothLeScanner
bleScanCallback = buildBleScanCallback()
txvStatus.text = "Scanning Obniz..."
executorService.execute {
bleScanner?.startScan(buildScanFilters(), buildScanSettings(), bleScanCallback)
}
btnScan.isEnabled = false
}
/**
* BLEデバイスと接続し,音の再生を開始します
*/
private fun startPlay() {
if (bleDevices.count() > 0) {
txvStatus.text = "Connecting to " + bleDevices.first().name + " ..."
connect(this, bleDevices.first())
txvStatus.text = "Connected to " + bleDevices.first().name
btnConnect.isEnabled = false
btnStop.isEnabled = true
// 音再生スレッドを開始
isStopRequested = false
thread {
var lastFreq: Double = Double.NaN
audioTrack?.play()
while (!isStopRequested) {
val data: RssiData? = receivedRssis.poll()
if(data != null){
lastFreq = 440.0 - data.rssi * 3
val soundData = soundGenerator.generate(lastFreq, 0.5)
audioTrack?.flush()
audioTrack?.write(soundData, 0, soundData.size)
GlobalScope.launch(Dispatchers.Main) {
txvRssi.text = data.rssi.toString()
txvTime.text = Date().toString()
txvFrequency.text = lastFreq.toString()
}
}
else if(lastFreq != Double.NaN){
val soundData = soundGenerator.generate(lastFreq, 0.5)
audioTrack?.flush()
audioTrack?.write(soundData, 0, soundData.size)
}
}
}
}
}
/**
* BLEデバイスとの接続を切断し,音の再生を停止します
*/
private fun stop() {
isStopRequested = true
txvStatus.text = "Disconnected from " + bluetoothGatt?.device?.name
audioTrack?.flush()
audioTrack?.stop()
bluetoothGatt?.disconnect()
btnScan.isEnabled = true
btnConnect.isEnabled = false
btnStop.isEnabled = false
}
/**
* Return a List of [android.bluetooth.le.ScanFilter] objects to filter by Service UUID.
*/
private fun buildScanFilters(): List<ScanFilter>? {
val scanFilters: MutableList<ScanFilter> = ArrayList()
val builder = ScanFilter.Builder()
builder.setDeviceName(OBNIZ_DEVICE_NAME)
scanFilters.add(builder.build())
return scanFilters
}
/**
* Return a [android.bluetooth.le.ScanSettings] object set to use low power (to preserve
* battery life).
*/
private fun buildScanSettings(): ScanSettings? {
val builder = ScanSettings.Builder()
builder.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
return builder.build()
}
/**
* BLEスキャンコールバックを作成します
*/
private fun buildBleScanCallback(): ScanCallback {
return object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
if (callbackType == ScanSettings.CALLBACK_TYPE_ALL_MATCHES && result.device != null) {
bleDevices.add(result.device)
txvDeviceAddress.text = result.device.address
txvTime.text = Date().toString()
bleScanner?.stopScan(bleScanCallback)
btnConnect.isEnabled = true;
}
}
}
}
/**
* GATTコールバックを作成します
*/
private fun buildGattCallback(): BluetoothGattCallback {
return object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState);
// 接続成功し、サービス取得
if (newState == BluetoothProfile.STATE_CONNECTED) {
bluetoothGatt = gatt;
GlobalScope.launch(Dispatchers.Main) {
txvStatus.text = "Connected to " + gatt.device.name;
}
gatt.readRemoteRssi()
discoverService();
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
super.onServicesDiscovered(gatt, status);
// 更新通知を購読
val service = gatt.getService(createUUID(0x1063))
if (service != null) {
val characteristic = service.getCharacteristic(createUUID(0x7777))
gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(createUUID(0x2902))
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?
) {
gatt?.readRemoteRssi()
}
override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) {
super.onReadRemoteRssi(gatt, rssi, status)
receivedRssis.add(RssiData(rssi, Date()))
}
}
}
/**
* GATTへの接続要求を出します
*/
private fun connect(context: Context?, device: BluetoothDevice) {
bluetoothGatt = device.connectGatt(context, false, buildGattCallback())
bluetoothGatt?.connect()
}
/**
* サービスを取得要求を出します
*/
private fun discoverService() {
bluetoothGatt?.discoverServices()
}
/**
* UUID作成ヘルパーメソッド
*/
private fun createUUID(bleUUID: Short): UUID {
return UUID(
0x0000_0000_0000_1000_UL.toLong() or (bleUUID.toLong() shl 32),
0x8000_0080_5f9b_34fb_UL.toLong()
)
}
}
RSSIのデータクラス
RssiData.kt
package dev.rioil.ble_theremin
import java.util.*
data class RssiData(val rssi: Int, val date: Date)
指定された周波数の音声データを作成するクラス
SoundGenerator.kt
package dev.rioil.ble_theremin
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.sin
class SoundGenerator(private val sampleRate: Int) {
val audioTrack: AudioTrack
get() {
return mAudioTrack
}
private val mAudioTrack: AudioTrack
private val bufferSize: Int
init {
val audioTrackBuilder: AudioTrack.Builder = AudioTrack.Builder()
audioTrackBuilder.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelIndexMask(AudioFormat.CHANNEL_OUT_MONO)
.setSampleRate(sampleRate)
.build()
)
audioTrackBuilder.setTransferMode(AudioTrack.MODE_STREAM)
bufferSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
audioTrackBuilder.setBufferSizeInBytes(bufferSize)
audioTrackBuilder.setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build()
)
mAudioTrack = AudioTrack(
AudioManager.STREAM_MUSIC,
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize,
AudioTrack.MODE_STREAM
)
//mAudioTrack = audioTrackBuilder.build()
}
fun generate(frequency: Double, lengthSecond: Double): ShortArray {
val buffer = ShortArray(ceil(sampleRate * lengthSecond).toInt())
for (i in buffer.indices) {
val t: Double = i / (sampleRate / frequency) * (PI * 2)
buffer[i] = (sin(t) * Short.MAX_VALUE).toInt().toShort()
}
return buffer
}
}
画面
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/btnScan"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan" />
<Button
android:id="@+id/btnConnect"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/connect" />
<Button
android:id="@+id/btnStop"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/stop" />
</LinearLayout>
<TableLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp">
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/lblDeviceAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:labelFor="@id/txvDeviceAddress"
android:text="@string/mac_address" />
<TextView
android:id="@+id/txvDeviceAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/lblLabelRssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:labelFor="@id/txvRssi"
android:text="@string/rssi" />
<TextView
android:id="@+id/txvRssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<Space
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/txvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/lblFrequency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:labelFor="@id/txvFrequency"
android:text="@string/frequency" />
<TextView
android:id="@+id/txvFrequency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="440" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/lblStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:labelFor="@id/txvStatus"
android:text="@string/status" />
<TextView
android:id="@+id/txvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="debug" />
</TableRow>
</TableLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
感想
obnizでBLEを使うのは普段JavaScriptを書かない自分でもすぐにできて,とても簡単でした.当初,セントラル側はWeb Bluetoothを使って作ろうとしたのですが,うまく動かず結局断念しました.そこで,Androidアプリとして作ることにしたのですが,ほとんど知識が無かった上に,調子に乗って書いたことのないKotlinを使おうとしたので作るのにとても時間がかかりました.obnizもAndroidもブロックプログラミングにすれば,もっと楽にできるかもしれないので試してみようと思っています(AndroidだとMIT App Inventorが使えそう).
参考にしたサイト
投稿者の人気記事
-
rioil
さんが
2021/05/15
に
編集
をしました。
(メッセージ: 初版)
-
rioil
さんが
2021/05/15
に
編集
をしました。
-
rioil
さんが
2021/05/16
に
編集
をしました。
-
rioil
さんが
2021/05/16
に
編集
をしました。
-
rioil
さんが
2021/05/16
に
編集
をしました。
-
rioil
さんが
2022/03/06
に
編集
をしました。
ログインしてコメントを投稿する