Compare commits
No commits in common. "37b646780d83f3068a8ef67b15af7475fdf24da3" and "5906af542b880d0700d6463a46aeb2a8dec40a77" have entirely different histories.
37b646780d
...
5906af542b
@ -1,46 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,444 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
<?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