編集履歴一覧に戻る
rioilのアイコン画像

rioil が 2021年05月16日13時52分16秒 に編集

コメント無し

記事種類の変更

+

製作品

本文の変更

# デモ動画

-

撮影が下手で見にくいので,撮り直しが上手くいけば差し替えます.

@[youtube](https://youtu.be/F4gkg4wliK0) # 部品 - obniz Board 1Y - BLEに対応したAndroid機器 # 動作の仕組み ## obniz側 1. Android機器とBLEで接続 1. 一定時間ごとにキャラクタリスティクスの値の更新通知を出す ## Android側 1. obnizとBLEで接続 1. キャラクタリスティクスの更新通知を購読する 1. 更新通知を受ければRSSI(電波強度)の値を読む 1. RSSIの値に応じて音の周波数を変える ![動作中の画面](https://camo.elchika.com/c6ce23ae5069c5b07d23e471e208701d654312e4/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f39626565366231352d663832342d346239302d396232342d3362633162386631373332392f34636137666633392d353534382d343164352d393237612d383965373738303839373635/) # ソースコード ## obniz側 ``` HTML <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機器側 ### メインアクティビティ 動作に必要な権限の取得処理は省略しています. ``` Kotlin: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のデータクラス ``` Kotlin:RssiData.kt package dev.rioil.ble_theremin import java.util.* data class RssiData(val rssi: Int, val date: Date) ``` ### 指定された周波数の音声データを作成するクラス ``` Kotlin: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 } } ``` ### 画面 ``` XML: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](https://appinventor.mit.edu/)が使えそう). # 参考にしたサイト [【クリスマスだし】Androidで8ビット音を生成してジングルベルを奏でてみる【25日目の1】](https://dev.classmethod.jp/articles/andoid_sound_generator_xmas/)