feat: Android BLE pairing UI for UWB tag (Issue #700) #701
46
android/build.gradle
Normal file
46
android/build.gradle
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'kotlin-android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk 34
|
||||||
|
namespace 'com.saltylab.uwbtag'
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.saltylab.uwbtag"
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
}
|
||||||
37
android/src/main/AndroidManifest.xml
Normal file
37
android/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- BLE permissions (API 31+) -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
|
||||||
|
<!-- Legacy BLE (API < 31) -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="UWB Tag Config"
|
||||||
|
android:theme="@style/Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".UwbTagBleActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
444
android/src/main/kotlin/com/saltylab/uwbtag/UwbTagBleActivity.kt
Normal file
444
android/src/main/kotlin/com/saltylab/uwbtag/UwbTagBleActivity.kt
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
package com.saltylab.uwbtag
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.*
|
||||||
|
import android.bluetooth.le.*
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.card.MaterialCardView
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.saltylab.uwbtag.databinding.ActivityUwbTagBleBinding
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GATT service / characteristic UUIDs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private val SERVICE_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef0")
|
||||||
|
private val CHAR_CONFIG_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef1") // read/write JSON config
|
||||||
|
private val CHAR_STATUS_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef2") // notify: tag status string
|
||||||
|
private val CHAR_BATT_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef3") // notify: battery %
|
||||||
|
private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
// BLE scan timeout
|
||||||
|
private const val SCAN_TIMEOUT_MS = 15_000L
|
||||||
|
|
||||||
|
// Permissions request code
|
||||||
|
private const val REQ_PERMISSIONS = 1001
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
data class TagConfig(
|
||||||
|
val tag_name: String = "UWB_TAG_0001",
|
||||||
|
val sleep_timeout_s: Int = 300,
|
||||||
|
val display_brightness: Int = 50,
|
||||||
|
val uwb_channel: Int = 9,
|
||||||
|
val ranging_interval_ms: Int = 100,
|
||||||
|
val battery_report: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScannedDevice(
|
||||||
|
val name: String,
|
||||||
|
val address: String,
|
||||||
|
var rssi: Int,
|
||||||
|
val device: BluetoothDevice
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RecyclerView adapter for scanned devices
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class DeviceAdapter(
|
||||||
|
private val onConnect: (ScannedDevice) -> Unit
|
||||||
|
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
|
||||||
|
|
||||||
|
private val items = mutableListOf<ScannedDevice>()
|
||||||
|
|
||||||
|
fun update(device: ScannedDevice) {
|
||||||
|
val idx = items.indexOfFirst { it.address == device.address }
|
||||||
|
if (idx >= 0) {
|
||||||
|
items[idx] = device
|
||||||
|
notifyItemChanged(idx)
|
||||||
|
} else {
|
||||||
|
items.add(device)
|
||||||
|
notifyItemInserted(items.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
items.clear()
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_ble_device, parent, false)
|
||||||
|
return VH(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position])
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val tvName = view.findViewById<TextView>(R.id.tvDeviceName)
|
||||||
|
private val tvAddress = view.findViewById<TextView>(R.id.tvDeviceAddress)
|
||||||
|
private val tvRssi = view.findViewById<TextView>(R.id.tvRssi)
|
||||||
|
private val btnConn = view.findViewById<Button>(R.id.btnConnect)
|
||||||
|
|
||||||
|
fun bind(item: ScannedDevice) {
|
||||||
|
tvName.text = item.name
|
||||||
|
tvAddress.text = item.address
|
||||||
|
tvRssi.text = "${item.rssi} dBm"
|
||||||
|
btnConn.setOnClickListener { onConnect(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Activity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@SuppressLint("MissingPermission") // permissions checked at runtime before any BLE call
|
||||||
|
class UwbTagBleActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var binding: ActivityUwbTagBleBinding
|
||||||
|
private val gson = Gson()
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
// BLE
|
||||||
|
private val btManager by lazy { getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager }
|
||||||
|
private val btAdapter by lazy { btManager.adapter }
|
||||||
|
private var bleScanner: BluetoothLeScanner? = null
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var configChar: BluetoothGattCharacteristic? = null
|
||||||
|
private var statusChar: BluetoothGattCharacteristic? = null
|
||||||
|
private var battChar: BluetoothGattCharacteristic? = null
|
||||||
|
private var isScanning = false
|
||||||
|
|
||||||
|
private val deviceAdapter = DeviceAdapter(onConnect = ::connectToDevice)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ActivityUwbTagBleBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
setSupportActionBar(binding.toolbar)
|
||||||
|
|
||||||
|
binding.rvDevices.layoutManager = LinearLayoutManager(this)
|
||||||
|
binding.rvDevices.adapter = deviceAdapter
|
||||||
|
|
||||||
|
binding.btnScan.setOnClickListener {
|
||||||
|
if (isScanning) stopScan() else startScanIfPermitted()
|
||||||
|
}
|
||||||
|
binding.btnDisconnect.setOnClickListener { disconnectGatt() }
|
||||||
|
binding.btnReadConfig.setOnClickListener { readConfig() }
|
||||||
|
binding.btnWriteConfig.setOnClickListener { writeConfig() }
|
||||||
|
|
||||||
|
requestBlePermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
stopScan()
|
||||||
|
disconnectGatt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permissions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private fun requestBlePermissions() {
|
||||||
|
val needed = mutableListOf<String>()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
if (!hasPermission(Manifest.permission.BLUETOOTH_SCAN))
|
||||||
|
needed += Manifest.permission.BLUETOOTH_SCAN
|
||||||
|
if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT))
|
||||||
|
needed += Manifest.permission.BLUETOOTH_CONNECT
|
||||||
|
} else {
|
||||||
|
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||||
|
needed += Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
}
|
||||||
|
if (needed.isNotEmpty()) {
|
||||||
|
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQ_PERMISSIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPermission(perm: String) =
|
||||||
|
ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == REQ_PERMISSIONS &&
|
||||||
|
grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
|
||||||
|
toast("BLE permissions required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BLE Scan
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private fun startScanIfPermitted() {
|
||||||
|
if (btAdapter?.isEnabled != true) { toast("Bluetooth is off"); return }
|
||||||
|
bleScanner = btAdapter.bluetoothLeScanner
|
||||||
|
val filter = ScanFilter.Builder()
|
||||||
|
.setDeviceNamePattern("UWB_TAG_.*".toRegex().toPattern())
|
||||||
|
.build()
|
||||||
|
val settings = ScanSettings.Builder()
|
||||||
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
|
.build()
|
||||||
|
deviceAdapter.clear()
|
||||||
|
bleScanner?.startScan(listOf(filter), settings, scanCallback)
|
||||||
|
isScanning = true
|
||||||
|
binding.btnScan.text = "Stop"
|
||||||
|
binding.tvScanStatus.text = "Scanning…"
|
||||||
|
mainHandler.postDelayed({ stopScan() }, SCAN_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopScan() {
|
||||||
|
bleScanner?.stopScan(scanCallback)
|
||||||
|
isScanning = false
|
||||||
|
binding.btnScan.text = "Scan"
|
||||||
|
binding.tvScanStatus.text = "Scan stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
val name = result.device.name ?: return
|
||||||
|
if (!name.startsWith("UWB_TAG_")) return
|
||||||
|
val dev = ScannedDevice(
|
||||||
|
name = name,
|
||||||
|
address = result.device.address,
|
||||||
|
rssi = result.rssi,
|
||||||
|
device = result.device
|
||||||
|
)
|
||||||
|
mainHandler.post { deviceAdapter.update(dev) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
mainHandler.post {
|
||||||
|
binding.tvScanStatus.text = "Scan failed (code $errorCode)"
|
||||||
|
isScanning = false
|
||||||
|
binding.btnScan.text = "Scan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GATT Connection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private fun connectToDevice(scanned: ScannedDevice) {
|
||||||
|
stopScan()
|
||||||
|
binding.tvScanStatus.text = "Connecting to ${scanned.name}…"
|
||||||
|
gatt = scanned.device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectGatt() {
|
||||||
|
gatt?.disconnect()
|
||||||
|
gatt?.close()
|
||||||
|
gatt = null
|
||||||
|
configChar = null
|
||||||
|
statusChar = null
|
||||||
|
battChar = null
|
||||||
|
mainHandler.post {
|
||||||
|
binding.cardConfig.visibility = View.GONE
|
||||||
|
binding.tvScanStatus.text = "Disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback = object : BluetoothGattCallback() {
|
||||||
|
|
||||||
|
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||||
|
when (newState) {
|
||||||
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
|
mainHandler.post { binding.tvScanStatus.text = "Connected — discovering services…" }
|
||||||
|
g.discoverServices()
|
||||||
|
}
|
||||||
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
|
mainHandler.post {
|
||||||
|
binding.cardConfig.visibility = View.GONE
|
||||||
|
binding.tvScanStatus.text = "Disconnected"
|
||||||
|
toast("Tag disconnected")
|
||||||
|
}
|
||||||
|
gatt?.close()
|
||||||
|
gatt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
mainHandler.post { toast("Service discovery failed") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val service = g.getService(SERVICE_UUID)
|
||||||
|
if (service == null) {
|
||||||
|
mainHandler.post { toast("UWB config service not found on tag") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configChar = service.getCharacteristic(CHAR_CONFIG_UUID)
|
||||||
|
statusChar = service.getCharacteristic(CHAR_STATUS_UUID)
|
||||||
|
battChar = service.getCharacteristic(CHAR_BATT_UUID)
|
||||||
|
|
||||||
|
// Subscribe to status notifications
|
||||||
|
statusChar?.let { enableNotifications(g, it) }
|
||||||
|
battChar?.let { enableNotifications(g, it) }
|
||||||
|
|
||||||
|
// Initial config read
|
||||||
|
configChar?.let { g.readCharacteristic(it) }
|
||||||
|
|
||||||
|
mainHandler.post {
|
||||||
|
val devName = g.device.name ?: g.device.address
|
||||||
|
binding.tvConnectedName.text = "Connected: $devName"
|
||||||
|
binding.cardConfig.visibility = View.VISIBLE
|
||||||
|
binding.tvScanStatus.text = "Connected to $devName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicRead(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
status: Int
|
||||||
|
) {
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) return
|
||||||
|
if (characteristic.uuid == CHAR_CONFIG_UUID) {
|
||||||
|
val json = characteristic.value?.toString(Charsets.UTF_8) ?: return
|
||||||
|
val cfg = runCatching { gson.fromJson(json, TagConfig::class.java) }.getOrNull() ?: return
|
||||||
|
mainHandler.post { populateFields(cfg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 33+ callback
|
||||||
|
override fun onCharacteristicRead(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
status: Int
|
||||||
|
) {
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) return
|
||||||
|
if (characteristic.uuid == CHAR_CONFIG_UUID) {
|
||||||
|
val json = value.toString(Charsets.UTF_8)
|
||||||
|
val cfg = runCatching { gson.fromJson(json, TagConfig::class.java) }.getOrNull() ?: return
|
||||||
|
mainHandler.post { populateFields(cfg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicWrite(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
status: Int
|
||||||
|
) {
|
||||||
|
val msg = if (status == BluetoothGatt.GATT_SUCCESS) "Config written" else "Write failed ($status)"
|
||||||
|
mainHandler.post { toast(msg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic
|
||||||
|
) {
|
||||||
|
val value = characteristic.value ?: return
|
||||||
|
handleNotification(characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 33+ callback
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
g: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray
|
||||||
|
) {
|
||||||
|
handleNotification(characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Notification helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private fun enableNotifications(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
|
||||||
|
g.setCharacteristicNotification(char, true)
|
||||||
|
val descriptor = char.getDescriptor(CCCD_UUID) ?: return
|
||||||
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
g.writeDescriptor(descriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNotification(uuid: UUID, value: ByteArray) {
|
||||||
|
val text = value.toString(Charsets.UTF_8)
|
||||||
|
mainHandler.post {
|
||||||
|
when (uuid) {
|
||||||
|
CHAR_STATUS_UUID -> binding.tvTagStatus.text = "Status: $text"
|
||||||
|
CHAR_BATT_UUID -> {
|
||||||
|
val pct = text.toIntOrNull() ?: return@post
|
||||||
|
binding.tvTagStatus.text = binding.tvTagStatus.text.toString()
|
||||||
|
.replace(Regex("\\| Batt:.*"), "")
|
||||||
|
.trimEnd() + " | Batt: $pct%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config read / write
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private fun readConfig() {
|
||||||
|
val g = gatt ?: run { toast("Not connected"); return }
|
||||||
|
val c = configChar ?: run { toast("Config char not found"); return }
|
||||||
|
g.readCharacteristic(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeConfig() {
|
||||||
|
val g = gatt ?: run { toast("Not connected"); return }
|
||||||
|
val c = configChar ?: run { toast("Config char not found"); return }
|
||||||
|
val cfg = buildConfigFromFields()
|
||||||
|
val json = gson.toJson(cfg)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
g.writeCharacteristic(c, json.toByteArray(Charsets.UTF_8),
|
||||||
|
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
c.value = json.toByteArray(Charsets.UTF_8)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
g.writeCharacteristic(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private fun populateFields(cfg: TagConfig) {
|
||||||
|
binding.etTagName.setText(cfg.tag_name)
|
||||||
|
binding.etSleepTimeout.setText(cfg.sleep_timeout_s.toString())
|
||||||
|
binding.etBrightness.setText(cfg.display_brightness.toString())
|
||||||
|
binding.etUwbChannel.setText(cfg.uwb_channel.toString())
|
||||||
|
binding.etRangingInterval.setText(cfg.ranging_interval_ms.toString())
|
||||||
|
binding.switchBatteryReport.isChecked = cfg.battery_report
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildConfigFromFields() = TagConfig(
|
||||||
|
tag_name = binding.etTagName.text?.toString() ?: "UWB_TAG_0001",
|
||||||
|
sleep_timeout_s = binding.etSleepTimeout.text?.toString()?.toIntOrNull() ?: 300,
|
||||||
|
display_brightness = binding.etBrightness.text?.toString()?.toIntOrNull() ?: 50,
|
||||||
|
uwb_channel = binding.etUwbChannel.text?.toString()?.toIntOrNull() ?: 9,
|
||||||
|
ranging_interval_ms = binding.etRangingInterval.text?.toString()?.toIntOrNull() ?: 100,
|
||||||
|
battery_report = binding.switchBatteryReport.isChecked
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun toast(msg: String) =
|
||||||
|
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
238
android/src/main/res/layout/activity_uwb_tag_ble.xml
Normal file
238
android/src/main/res/layout/activity_uwb_tag_ble.xml
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:background="?attr/colorPrimary"
|
||||||
|
android:elevation="4dp"
|
||||||
|
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
||||||
|
app:title="UWB Tag BLE Config" />
|
||||||
|
|
||||||
|
<!-- Scan controls -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnScan"
|
||||||
|
style="@style/Widget.MaterialComponents.Button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Scan" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvScanStatus"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:text="Tap Scan to find UWB tags"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Scan results list -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:text="Nearby Tags"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvDevices"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
|
<!-- Connected device config panel -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardConfig"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvConnectedName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Connected: —"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnDisconnect"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Disconnect" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- tag_name -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="Tag Name">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etTagName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- sleep_timeout_s and uwb_channel (row) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="4dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:hint="Sleep Timeout (s)">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etSleepTimeout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:hint="UWB Channel">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etUwbChannel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- display_brightness and ranging_interval_ms (row) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="4dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:hint="Brightness (0-100)">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etBrightness"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:hint="Ranging Interval (ms)">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etRangingInterval"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- battery_report toggle -->
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchBatteryReport"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Battery Reporting" />
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnReadConfig"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:text="Read" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnWriteConfig"
|
||||||
|
style="@style/Widget.MaterialComponents.Button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="Write" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Status notifications from tag -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTagStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="#1A000000"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:text="Tag status: —"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
60
android/src/main/res/layout/item_ble_device.xml
Normal file
60
android/src/main/res/layout/item_ble_device.xml
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="4dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDeviceName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="UWB_TAG_XXXX"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDeviceAddress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="XX:XX:XX:XX:XX:XX"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRssi"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="-70 dBm"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
|
||||||
|
android:textColor="?attr/colorSecondary" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnConnect"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Connect" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
Loading…
x
Reference in New Issue
Block a user