たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 8349
目次
本題
公式
UWB とは
UWB どこで使ってるの
UWB 誰もやってない
UWB 対応端末を2台用意する必要がある
UWB は通信する機能しかなく、発見する別の仕組みが必要
環境
今回の作戦
流れ
つくる
必要なライブラリを入れる
権限を書く
MainActivity
最初の権限ください画面
BLE 周りを作る
GATT のサービスとキャラクタリスティックの UUID
BLE ペリフェラル側のコード
BLE セントラル側のコード
BLE で実際にやり取りするデータのデータクラス
実際に UWB の部分を書いていく
UWB Controller(ホスト) 側の画面
UWB Controlee(ゲスト) 側の画面
UWB 使ってみる!!!
UWB 注意事項
動かない時
こんな感じ
アクセサリの位置を探す矢印みたいなやつは?
相手の位置を表示する Canvas
おまけ UWB デバイスを動かして軌跡を描く
そーすこーど
おわりに
どうもこんばんわ。
シークレットラブ(仮) 攻略しました。涼しそうな制服ですねって言おうとしたらもう寒い時期。
それはそれとして今作の HOOK 結構おもしろかった。セーブ枠が足りない。あとえちえちだった。
今作は特にみんなかわいい、しかもきれい。買う前と共通やった後で誰から攻略するか変わった。
ハルちゃんのここのシナリオすき、>< かわいい
顔が良すぎる
こちら後輩ちゃんです。売り文句どおりえちえちだった。。
楓ちゃんルートが一番おもしろいかもしれん!
でもやっぱちあきちゃんが一番良かったかも
!?!?
ん~
それはそれとして、他のヒロイン選んだときに真っ先にちあきちゃん飛んでくるのが心に来る
だから最後にするといいのかな、ヨカッタ
あとはオープンルートのが掛け合いがあるので面白かったけどクローズドの方にも好きなシナリオあるから一概に言えない!!
↑ここすき
いい!!とてもいいです。おすすすすめです
Pixel 6 Pro
以降のPro
モデルにはUWB アンテナ
が搭載されていて?、API
も用意されているわけですがあんまり情報がないので、
今回は試しにUWB
のAPI
を使ってお互いの位置を見れるアプリを作ってみようと思います。
なんならUWB
あるのが忘れられている可能性・・・?
まじでこれしか無い。
なんなら2つ目のYouTube
の動画のほうが詳しく話してる。
近くの端末と通信する技術で、他のそれと違ってかなり正確な位置検出が出来る。位置測定に関してはセンチメートルの単位で報告される。(体感10cm
前後くらいの誤差)
あとは高速通信があるらしいですが、今のところAndroid
のUWB
にはデータ通信のAPI
は無さそう?
ドキュメントを見る限り位置情報に関してしか無い。
ニアバイシェア
の際に共有する端末に近付けると勝手に転送が始まる。端末を選ぶ作業がスキップされる。
あとは・・・
しかしUWB
を試すには地味にハードルが高い。
https://developer.android.com/develop/connectivity/uwb#uwb-enabled_mobile_devices
多分これのせい。
今のところPixel
のPro
シリーズとGalaxy
には搭載されているそう。
・・・高い。
どういうことかというと携帯電話を持っていても相手の電話番号が分からなければ電話をかけることが出来ない。
UWB
も同じで、UWB
通信を開始するためのパラメーターを何らかの方法でお互いに送受信する必要があり、これも地味にハードルが高い。
それこそ例えば、前回の記事でやったBluetooth Low Energy
のキャラクタリスティック
で読み書きしパラメーターを交換する必要がある。
UWB
のパラメーターも多分そんな複雑じゃないからキーボードで打ち込んでもらうでも最悪いいはず。
今回はPixel 6 Pro
とPixel 8 Pro
があるのでそれを使います。
あとUWB
のライブラリがKotlin Coroutines Flow
を使っているのでKotlin
です。Jetpack Compose
使いたいのでそれはそう。
UWB
自体はFlow
かRx
のどっちか選べるらしい。Flow
しかわからん無いのでそっちで。
端末 | Pixel 6 Pro / Pixel 8 Pro |
Android Studio | Android Studio Ladybug 2024.2.1 Patch 2 |
targetSdk | 31 ? |
そのほか | Jetpack Compose + Navigation Compose |
言語 | Kotlin |
UWB
でお互いに交換する必要があるパラメーターはdata class
に詰めてSerializable
にした後BLE
経由で交換します。
BLE
でやり取りする話は前回の記事でやったので今回は手短にします。
https://takusan.negitoro.dev/posts/android_ble_peripheral_central/
ちなみにGoogle
が書いたUWB
サンプルコードはNearby API
で交換してるっぽい。
ただ、Nearby API
にはAPI キー
の払い出しが必要なはずでそれはそれで面倒。
https://github.com/android/connectivity-samples/tree/main/UwbRanging
なので、流れとしては、
Controller
になり、もう片方がControlee
になるUWB
接続に必要なパラメーターを受け取り、BLE
のキャラクタリスティック
に読み書きして交換するUWB
を開始する後述しますが、親→子は複数の値を渡す必要がある、逆に子→親は自分のアドレス(ByteArray
)を渡すだけなので楽。
Jetpack Compose
で適当にプロジェクトを作ってください。
app/build.gradle.kts
にUWB
のライブラリとnavigation-compose
を入れてね。バージョンカタログ入ってるならそっちに書くべきです。
何故かUWB
はAndroid Jetpack
からの提供になります。普通にgetSystemService()
するもんだと思ってたら違った。
dependencies {
// UWB
implementation("androidx.core.uwb:uwb:1.0.0-alpha09")
// navigation compose
implementation("androidx.navigation:navigation-compose:2.8.4")
// 以下省略
まじで情報がさっきのYouTube
とサンプルコードくらいしか無いんですが、多分android.permission.UWB_RANGING
ってのが必要。
後はBLE
のための権限が続きます。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth Low Energy -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- UWB -->
<uses-permission android:name="android.permission.UWB_RANGING" />
でNavigation Compose
のアレコレをします。
各画面はまだ作ってないのでエラーになると思います。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AndroidBleAndUwbSampleTheme {
MainScreen()
}
}
}
}
@Composable
private fun MainScreen() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(
onControllerClick = { navController.navigate("controller") },
onControleeClick = { navController.navigate("controlee") }
)
}
composable("controller") {
ControllerScreen()
}
composable("controlee") {
ControleeScreen()
}
}
}
BLE
のそれと同じなので解説はコードのコメントくらいしか無いです。
Controller側
(親機側)になるか、Controlee側
(子機側)になるかを選べる画面です。
private val REQUIRED_PERMISSION = listOf(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_ADVERTISE,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.UWB_RANGING
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onControllerClick: () -> Unit,
onControleeClick: () -> Unit
) {
val context = LocalContext.current
val isGranted = remember {
mutableStateOf(REQUIRED_PERMISSION.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED })
}
val permissionRequest = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { isGranted.value = it.all { it.value } }
)
LaunchedEffect(key1 = Unit) {
// 権限をリクエスト
permissionRequest.launch(REQUIRED_PERMISSION.toTypedArray())
}
Scaffold(
topBar = {
TopAppBar(title = { Text(text = "権限ください") })
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
// 権限が付与されるまでボタンを出さない
if (!isGranted.value) {
Text(text = "権限が付与されていません")
return@Scaffold
}
// 画面遷移用
Button(onClick = onControllerClick) {
Text(text = "Controller (Host)")
}
Button(onClick = onControleeClick) {
Text(text = "Controlee (Guest)")
}
}
}
}
さて、先にBLE
でUWB
開始に必要なパラメーター交換周りを作ります。
詳しくは前回のBLE
でペリフェラル、セントラル
を試す記事を読んでください。
再掲:
https://takusan.negitoro.dev/posts/android_ble_peripheral_central/
を適当に作ったのでそれを使います。
/** BLE で使う UUID */
object BleUuid {
/** GATT サービスの UUID */
val GATT_SERVICE_UUID = UUID.fromString("107c9e9b-bf6d-4b64-ab30-0bd96fdd2537")
/** GATT キャラクタリスティックの UUID */
val GATT_CHARACTERISTIC_UUID = UUID.fromString("e42ba363-eeaa-4e46-b7aa-049c19341f24")
}
これも前回の記事でやったので。。
/** BLE ペリフェラル側のコード */
object BlePeripheral {
/**
* ペリフェラル側に必要な GATT サーバーとアドバタイジングを開始する。
* コルーチンをキャンセルすると終了する。
*
* @param context [Context]
* @param onCharacteristicReadRequest セントラルからキャラクタリスティックに対して read 要求された時
* @param onCharacteristicWriteRequest セントラルからキャラクタリスティックに対して write 要求された時
*/
suspend fun startPeripheralAndAdvertising(
context: Context,
onCharacteristicReadRequest: () -> ByteArray,
onCharacteristicWriteRequest: (ByteArray) -> Unit
) {
coroutineScope {
launch {
suspendGattServer(context, onCharacteristicReadRequest, onCharacteristicWriteRequest)
}
launch {
suspendAdvertisement(context)
}
}
}
@SuppressLint("MissingPermission")
private suspend fun suspendGattServer(
context: Context,
onCharacteristicReadRequest: () -> ByteArray,
onCharacteristicWriteRequest: (ByteArray) -> Unit
) {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
var bleGattServer: BluetoothGattServer? = null
bleGattServer = bluetoothManager.openGattServer(context, object : BluetoothGattServerCallback() {
// readCharacteristic が要求されたら呼ばれる
// セントラルへ送信する
override fun onCharacteristicReadRequest(device: BluetoothDevice?, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic?) {
super.onCharacteristicReadRequest(device, requestId, offset, characteristic)
val sendByteArray = onCharacteristicReadRequest()
// オフセットを考慮する
// TODO バイト数スキップするのが面倒で ByteArrayInputStream 使ってるけど多分オーバースペック
val sendOffsetByteArray = sendByteArray.inputStream().apply { skip(offset.toLong()) }.readBytes()
bleGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, sendOffsetByteArray)
}
// writeCharacteristic が要求されたら呼ばれる
// セントラルから受信する
override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) {
super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value)
value ?: return
onCharacteristicWriteRequest(value)
bleGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
})
//サービスとキャラクタリスティックを作る
val gattService = BluetoothGattService(BleUuid.GATT_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
val gattCharacteristics = BluetoothGattCharacteristic(
BleUuid.GATT_CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE
)
// サービスに Characteristic を入れる
gattService.addCharacteristic(gattCharacteristics)
// GATT サーバーにサービスを追加
bleGattServer?.addService(gattService)
// キャンセルしたら終了
try {
awaitCancellation()
} finally {
bleGattServer?.close()
}
}
@SuppressLint("MissingPermission")
private suspend fun suspendAdvertisement(context: Context) {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothLeAdvertiser = bluetoothManager.adapter.bluetoothLeAdvertiser
// アドバタイジング。これがないと見つけてもらえない
val advertiseSettings = AdvertiseSettings.Builder().apply {
setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
setTimeout(0)
}.build()
val advertiseData = AdvertiseData.Builder().apply {
addServiceUuid(ParcelUuid(BleUuid.GATT_SERVICE_UUID))
}.build()
// アドバタイジング開始
val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
}
}
bluetoothLeAdvertiser.startAdvertising(advertiseSettings, advertiseData, advertiseCallback)
// キャンセルしたら終了
try {
awaitCancellation()
} finally {
bluetoothLeAdvertiser.stopAdvertising(advertiseCallback)
}
}
}
これも前回のようなコードを書きます。
/** BLE セントラル側のコード */
class BleCentral(private val context: Context) {
/** [readCharacteristic]等で使いたいので */
private val _bluetoothGatt = MutableStateFlow<BluetoothGatt?>(null)
/** コールバックの返り値をコルーチン側から受け取りたいので */
private val _characteristicReadChannel = Channel<ByteArray>()
/** BLE 通信をし、GATT サーバーへ接続しサービスを探す */
@SuppressLint("MissingPermission")
suspend fun connectGattServer() {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
// BluetoothDevice が見つかるまで一時停止
val bluetoothDevice: BluetoothDevice? = suspendCoroutine { continuation ->
val bluetoothLeScanner = bluetoothManager.adapter.bluetoothLeScanner
val bleScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
super.onScanResult(callbackType, result)
// 見つけたら返して、スキャンも終了させる
continuation.resume(result?.device)
bluetoothLeScanner.stopScan(this)
}
override fun onScanFailed(errorCode: Int) {
super.onScanFailed(errorCode)
continuation.resume(null)
}
}
// GATT サーバーのサービス UUID を指定して検索を始める
val scanFilter = ScanFilter.Builder().apply {
setServiceUuid(ParcelUuid(BleUuid.GATT_SERVICE_UUID))
}.build()
bluetoothLeScanner.startScan(
listOf(scanFilter),
ScanSettings.Builder().build(),
bleScanCallback
)
}
// BLE デバイスを見つけたら、GATT サーバーへ接続
bluetoothDevice?.connectGatt(context, false, object : BluetoothGattCallback() {
// ペリフェラル側との接続
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
when (newState) {
// 接続できたらサービスを探す
BluetoothProfile.STATE_CONNECTED -> gatt?.discoverServices()
// なくなった
BluetoothProfile.STATE_DISCONNECTED -> _bluetoothGatt.value = null
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
// サービスが見つかったら GATT サーバーに対して操作ができるはず
// サービスとキャラクタリスティックを探して、read する
// キャラクタリスティック操作ができたら flow に入れる
_bluetoothGatt.value = gatt
}
// onCharacteristicReadRequest で送られてきたデータを受け取る
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
super.onCharacteristicRead(gatt, characteristic, value, status)
_characteristicReadChannel.trySend(value)
}
})
// GATT サーバーへ接続できるまで一時停止する
_bluetoothGatt.first { it != null }
}
/** 終了時に呼ぶ */
@SuppressLint("MissingPermission")
fun destroy() {
_bluetoothGatt.value?.close()
_bluetoothGatt.value = null
}
/** キャラクタリスティックから読み出す */
@SuppressLint("MissingPermission")
suspend fun readCharacteristic(): ByteArray {
// GATT サーバーとの接続を待つ
val gatt = _bluetoothGatt.filterNotNull().first()
// GATT サーバーへ狙ったサービス内にあるキャラクタリスティックへ read を試みる
val findService = gatt.services?.first { it.uuid == BleUuid.GATT_SERVICE_UUID }
val findCharacteristic = findService?.characteristics?.first { it.uuid == BleUuid.GATT_CHARACTERISTIC_UUID }
// 結果は onCharacteristicRead で
gatt.readCharacteristic(findCharacteristic)
return _characteristicReadChannel.receive()
}
/** キャラクタリスティックへ書き込む */
@SuppressLint("MissingPermission")
suspend fun writeCharacteristic(sendData: ByteArray) {
// GATT サーバーとの接続を待つ
val gatt = _bluetoothGatt.filterNotNull().first()
// GATT サーバーへ狙ったサービス内にあるキャラクタリスティックへ write を試みる
val findService = gatt.services?.first { it.uuid == BleUuid.GATT_SERVICE_UUID } ?: return
val findCharacteristic = findService.characteristics?.first { it.uuid == BleUuid.GATT_CHARACTERISTIC_UUID } ?: return
// 結果は onCharacteristicWriteRequest で
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(findCharacteristic, sendData, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
// TODO 下位バージョン対応するなら。UWB 対応デバイスが、TIRAMISU より前に存在するかを考えるとめんどい
}
}
}
さて、UWB
を開始するために必要なパラメーターなのですが、Controller(親)→Controlee(子)
へ送る必要がある値が複数個あるんですね。
ちなみにControlee(子)→Controller(親)
は1つのバイト配列を投げれば終わり。
というわけで何らかの方法で1つのバイト配列に変換しちゃいたいわけです。
今ならprotobuf
なんでしょうが、私は使ったことがないので大人しくJava
のSerializable
でdata class
をバイト配列に変換しようと思います。。。
/** Controller(親)→Controlee(子) へ送るパラメーター */
data class UwbControllerParams(
val address: ByteArray,
val channel: Int,
val preambleIndex: Int,
val sessionId: Int,
val sessionKeyInfo: ByteArray
) : Serializable {
/** シリアライズ、デシリアライズ用 */
companion object {
fun encode(uwbHostParameter: UwbControllerParams): ByteArray {
return ByteArrayOutputStream().use { byteArrayOutputStream ->
ObjectOutputStream(byteArrayOutputStream).use { objectOutputStream ->
// 書き込んで ByteArray を返す
objectOutputStream.writeObject(uwbHostParameter)
byteArrayOutputStream.toByteArray()
}
}
}
fun decode(byteArray: ByteArray): UwbControllerParams {
return byteArray.inputStream().use { byteArrayInputStream ->
ObjectInputStream(byteArrayInputStream).use { objectInputStream ->
// キャストする
objectInputStream.readObject() as UwbControllerParams
}
}
}
}
}
ついに来ました。まずはController
(ホスト)側から!
ついにドキュメントが役に立ちそうなところまで進んできました。
まずは実際にUI
で表示するためRangingPosition
をremember { mutableStateOf }
で作っておきます。
で、LaunchedEffect
の中でBLE
からのUWB
をやっています。
Controller
側になるにはUwbManager#controllerSessionScope
を呼び出します。あ、まずUWB
があるかの確認をしたほうが良さそうですね。めんどいのでやりません。
Controlee側
(ゲスト側)に送らないといけない値は以下で、
controllerSession()
から取得できるlocalAddress.address
、uwbComplexChannel.channel
、uwbComplexChannel.preambleIndex
、
それからsessionId
とsessionKeyInfo
を適当に作る必要があるらしいです。サンプルコードでも適当に作ってたので適当に作りました。
この値たちをデータクラスにした後、Serializable
なのでバイト配列に変換し、BLE
のキャラクタリスティック
のread要求
でこのバイト配列を送るようにします。
また、Controlee
側をまだ作っていないのであれですが、Controlee
側からもByteArray
のアドレスを受け取る必要があるので、write要求
されるまで待ちます。
Controlee
側からのアドレスが受信できればRangingParameters()
の値が全て揃います。
詳しい引数はよくわからずで、とりあえずコレで動きました。
最後にControllerSession#prepareSession
にRangingParameters
を入れてFlow
をcollect { }
するとControlee
側の位置の情報が取得できるようになります。
適当に受け取った位置情報はText()
で表示するようにしました。
/** Controller(Host) 側の画面 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControllerScreen() {
val context = LocalContext.current
// controlee の位置
val uwbPosition = remember { mutableStateOf<RangingPosition?>(null) }
LaunchedEffect(key1 = Unit) {
// controller 側として作成
val uwbManager = UwbManager.createInstance(context)
val controllerSession = uwbManager.controllerSessionScope()
// ゲスト側へ送るパラメーターを ByteArray にして送る
// sessionId / sessionKeyInfo はサンプルコードでも適当に作ってるので適当に作る
// https://github.com/android/connectivity-samples/blob/777517eb2898cd48e139446246808a2106d343cc/UwbRanging/uwbranging/src/main/java/com/google/apps/uwbranging/impl/NearbyControllerConnector.kt#L69
val sessionId = Random.nextInt()
val sessionKeyInfo = Random.nextBytes(8)
// Serializable な data class にして ByteArray にエンコードする
val uwbControllerParams = UwbControllerParams(
address = controllerSession.localAddress.address,
channel = controllerSession.uwbComplexChannel.channel,
preambleIndex = controllerSession.uwbComplexChannel.preambleIndex,
sessionId = sessionId,
sessionKeyInfo = sessionKeyInfo
)
// バイト配列に
val encodeHostParameter = UwbControllerParams.encode(uwbControllerParams)
// Controlee 側からアドレスが送られてきたら入れる Flow
val controleeAddressFlow = MutableStateFlow<ByteArray?>(null)
// BLE の開始
val peripheralJob = launch {
BlePeripheral.startPeripheralAndAdvertising(
context = context,
onCharacteristicReadRequest = {
// controlee へ送る
encodeHostParameter
},
onCharacteristicWriteRequest = {
// controlee から受け取る
controleeAddressFlow.value = it
}
)
}
// アドレスが送られてきたらペリフェラル終了
val controleeAddress = controleeAddressFlow.filterNotNull().first()
peripheralJob.cancel()
// RangingParameters を作り UWB 接続を開始する
val rangingParameters = RangingParameters(
uwbConfigType = RangingParameters.CONFIG_MULTICAST_DS_TWR,
complexChannel = controllerSession.uwbComplexChannel,
peerDevices = listOf(UwbDevice.createForAddress(controleeAddress)),
updateRateType = RangingParameters.RANGING_UPDATE_RATE_AUTOMATIC,
sessionId = sessionId,
sessionKeyInfo = sessionKeyInfo,
subSessionId = 0, // SUB_SESSION_UNSET
subSessionKeyInfo = null // 暗号化の何か
)
controllerSession.prepareSession(rangingParameters).collect { rangingResult ->
when (rangingResult) {
is RangingResult.RangingResultPosition -> {
uwbPosition.value = rangingResult.position
}
is RangingResult.RangingResultPeerDisconnected -> {
uwbPosition.value = null
}
}
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(text = "UWB Controller") })
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
// null になりえるので注意
Text(text = "距離 = ${uwbPosition.value?.distance?.value} m")
Text(text = "方位角 = ${uwbPosition.value?.azimuth?.value} 度")
Text(text = "仰角 = ${uwbPosition.value?.elevation?.value} 度")
}
}
}
こちらも同様、RangingPosition
をremember stateof
で持っておきます。
で、Controlee側
はUwbManager#controleeSessionScope
で作れます。
つぎに、BLE
を使い、Controller側
のペリフェラルへ接続し、キャラクタリスティックへread
することでUWB
に必要なパラメーターを受信します。Serializable
なByteArray
なのでデータクラスの状態戻します。
Controller側
で話しましたが、こっちはlocalAddress.address
1つをController
側へ送るだけなので楽です。
そしたらRangingParameters
が作成できるので、あとは同じです。
/** Controlee(Guest) 側の画面 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControleeScreen() {
val context = LocalContext.current
// controller の位置
val uwbPosition = remember { mutableStateOf<RangingPosition?>(null) }
LaunchedEffect(key1 = Unit) {
val uwbManager = UwbManager.createInstance(context)
val controleeSession = uwbManager.controleeSessionScope()
// ホスト側へ送るデータ
val addressByteArray = controleeSession.localAddress.address
// BLE GATT サーバーへ接続し、UWB ホストと接続に必要なパラメーターを送受信する
val bleCentral = BleCentral(context)
bleCentral.connectGattServer()
val uwbControllerParamsByteArray = bleCentral.readCharacteristic()
val uwbControllerParams = UwbControllerParams.decode(uwbControllerParamsByteArray)
bleCentral.writeCharacteristic(addressByteArray)
bleCentral.destroy()
// パラメーターを作成
val rangingParameters = RangingParameters(
uwbConfigType = RangingParameters.CONFIG_MULTICAST_DS_TWR,
complexChannel = UwbComplexChannel(uwbControllerParams.channel, uwbControllerParams.preambleIndex),
peerDevices = listOf(UwbDevice.createForAddress(uwbControllerParams.address)),
updateRateType = RangingParameters.RANGING_UPDATE_RATE_AUTOMATIC,
sessionId = uwbControllerParams.sessionId,
sessionKeyInfo = uwbControllerParams.sessionKeyInfo,
subSessionId = 0, // SESSION_ID_UNSET ?
subSessionKeyInfo = null // ?
)
// Flow で UWB デバイスとの接続状況をもらえる
controleeSession.prepareSession(rangingParameters).collect { rangingResult ->
when (rangingResult) {
is RangingResult.RangingResultPosition -> {
uwbPosition.value = rangingResult.position
}
is RangingResult.RangingResultPeerDisconnected -> {
uwbPosition.value = null
}
}
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(text = "UWB Controlee") })
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
// null になりえるので注意
Text(text = "距離 = ${uwbPosition.value?.distance?.value} m")
Text(text = "方位角 = ${uwbPosition.value?.azimuth?.value} 度")
Text(text = "仰角 = ${uwbPosition.value?.elevation?.value} 度")
}
}
}
アプリを実行してみます。
ドキュメントには書いてないのですが、注意事項がいくつかあります。iPhone
のドキュメントをチラ見しましたが多分Android
もそうです。
ドキュメントに書いてないけど多分そういう仕様。
logcat
でUwbBackend
でTAG
を探して見てみるといいかも。ちなみに私は権限が付与されてないことに30分くらい気付かなかった。。。
こんな感じ。手元で見る感じ誤差はざっくりプラマイ10cm
くらいかな?
すごい
YouTube
の動画を見た感じ、距離に加えてazimuth
ってので角度を取得できるらしい。
ところで試したところ、なんかドキュメントだと90, -90
の範囲って書いてあって、
でも画面に表示されてるのは-148
で普通に超えてる気がするんだけどどういうことなの?
https://developer.android.com/reference/androidx/core/uwb/RangingPosition#getAzimuth()
というわけで矢印コンポーネントを用意しました。
矢印の記号をrotationZ
しています。
/** UWB の方角を表示する矢印 */
@Composable
fun UwbArrow(
modifier: Modifier = Modifier,
azimuth: Float
) {
val animateAzimuth = animateFloatAsState(azimuth, label = "animateAzimuth")
Box(
modifier = modifier.graphicsLayer {
rotationZ = animateAzimuth.value
},
contentAlignment = Alignment.Center
) {
Text(
text = "↑",
fontSize = 100.sp
)
}
}
あとはControllerScreen / ControleeScreen
で呼び出せばいいはず!
// null になりえるので注意
Text(text = "距離 = ${uwbPosition.value?.distance?.value} m")
Text(text = "方位角 = ${uwbPosition.value?.azimuth?.value} 度")
Text(text = "仰角 = ${uwbPosition.value?.elevation?.value} 度")
UwbArrow(
azimuth = uwbPosition.value?.azimuth?.value ?: 0f
)
こんな感じに矢印が出て、この矢印がまっすぐになった方向に歩くと見つかります。
結構正確です。
サンプルアプリでは、自分の位置を中心に、どのへんにUWB
接続相手がいるかをレーダーみたいに表示するUI
があるっぽいです。
これをパクってみます。
元ネタはこの辺です。
https://github.com/android/connectivity-samples/blob/main/UwbRanging/app/src/main/java/com/google/apps/hellouwb/ui/home/HomeScreen.kt
Apache-2.0 license
まずはCanvas
を用意し、UWB
デバイスの位置を表す点を書きます。
/**
* 自分と通信相手を点で表示する Canvas
* https://github.com/android/connectivity-samples/blob/main/UwbRanging/app/src/main/java/com/google/apps/hellouwb/ui/home/HomeScreen.kt
*
* @param modifier [Modifier]
* @param distance 距離
* @param azimuth 角度
* @param isInvert 動かす側の場合は反転する必要があるので
*/
@Composable
fun UwbPointCanvas(
modifier: Modifier = Modifier,
isInvert: Boolean,
distance: Float,
azimuth: Float
) {
Canvas(modifier = modifier.border(1.dp, Color.Black)) {
// 自分(isInvert した場合は相手)
drawCircle(Color.Red, radius = 15.0f)
val scale = size.minDimension / 20.0f
val angle = azimuth * PI / 180
val x = distance * sin(angle).toFloat()
val y = distance * cos(angle).toFloat()
// UWB デバイスの位置
drawCircle(
center = center.plus(
if (isInvert) {
Offset(-x * scale, y * scale)
} else {
Offset(x * scale, -y * scale)
}
),
color = Color.Blue,
radius = 15.0f
)
}
}
これをControllerScreen / ControleeScreen
で呼び出せばよいです。
もう一方の端末ではisInvert
をtrue
にして自分と相手を入れ替える必要がある。多分。
// null になりえるので注意
Text(text = "距離 = ${uwbPosition.value?.distance?.value} m")
Text(text = "方位角 = ${uwbPosition.value?.azimuth?.value} 度")
Text(text = "仰角 = ${uwbPosition.value?.elevation?.value} 度")
UwbArrow(
azimuth = uwbPosition.value?.azimuth?.value ?: 0f
)
val isCanvasInvert = remember { mutableStateOf(false) }
Row {
Text(text = "canvas を反転")
Switch(checked = isCanvasInvert.value, onCheckedChange = { isCanvasInvert.value = it })
}
UwbPointCanvas(
modifier = Modifier.size(300.dp),
isInvert = isCanvasInvert.value,
distance = uwbPosition.value?.distance?.value ?: 0f,
azimuth = uwbPosition.value?.azimuth?.value ?: 0f
)
こんな感じに自分と相手の位置が点で表示される。上から見た図ですね。
Jetpack Compose
数年使ってる気がするけど始めてCanvas
使ったかもしれない。
点の動きを記録して、線を書いてみる。数が多くなるので適当に捨てます。
さっきのCanvas
に書いてたやつを転用し、引数の値を配列に記録するように改造し、点を描画する際にはその配列から取り出すようにします。
開始、終了ボタン、リセットボタンをおきました。
@Composable
fun UwbRecordPointCanvas(
modifier: Modifier = Modifier,
isInvert: Boolean,
distance: Float,
azimuth: Float
) {
val isRecord = remember { mutableStateOf(false) }
val recordList = remember { mutableStateOf(emptyList<PointData>()) }
if (isRecord.value) {
SideEffect {
// 数が多いので適当に捨てる
if (Random.nextBoolean()) {
recordList.value += PointData(distance, azimuth)
}
}
}
Column {
Row {
Button(onClick = { isRecord.value = !isRecord.value }) {
Text(text = if (!isRecord.value) "記録開始" else "終了")
}
Button(onClick = { recordList.value = emptyList() }) {
Text(text = "クリア")
}
}
Canvas(modifier = modifier.border(1.dp, Color.Black)) {
// 自分(isInvert した場合は相手)
drawCircle(Color.Red, radius = 15.0f)
// 配列に入れたものを表示
recordList.value.forEach { (distance, azimuth) ->
val scale = size.minDimension / 20.0f
val angle = azimuth * PI / 180
val x = distance * sin(angle).toFloat()
val y = distance * cos(angle).toFloat()
// UWB デバイスの位置
drawCircle(
center = center.plus(
if (isInvert) {
Offset(-x * scale, y * scale)
} else {
Offset(x * scale, -y * scale)
}
),
color = Color.Blue,
radius = 15.0f
)
}
}
}
}
あとは片方の端末で記録ボタンを押し、もう一方のUWB
端末に動いてもらえばいいはず。
でもあんまりうまく取れてない。
どーぞ
UWB
対応の2台の端末にビルドしたアプリを入れて、片方でController
を開始したあともう片方をControlee
にしてしばらく待ってると位置とかが表示されるようになるはず。
https://github.com/takusan23/AndroidBleAndUwbSample
2台それぞれにインストールするのが大変でした。
以上です。88888888