Initial commit: VesiscanBasic Android app
Port of MedilightV2 iOS app to Android with Kotlin + Jetpack Compose. Includes BLE connectivity, NIRS/Piezo sensor modes, Rive animations, interactive measurement history charts, and foldable device support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
package com.example.medilightv2android.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.*
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import java.util.UUID
|
||||
|
||||
data class BleDevice(
|
||||
val device: BluetoothDevice,
|
||||
var rssi: Int,
|
||||
var isConnecting: Boolean = false,
|
||||
var isConnected: Boolean = false
|
||||
) {
|
||||
val name: String @SuppressLint("MissingPermission") get() = try { device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
||||
val address: String get() = device.address
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleManager private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BleManager"
|
||||
|
||||
@Volatile
|
||||
private var instance: BleManager? = null
|
||||
fun getInstance(context: Context): BleManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: BleManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
val SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||
val TX_CHAR_UUID: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||
val RX_CHAR_UUID: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
private var bluetoothLeScanner: BluetoothLeScanner? = null
|
||||
private var bluetoothGatt: BluetoothGatt? = null
|
||||
private var txCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var rxCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var scanTimer: Runnable? = null
|
||||
private var batteryTimer: Runnable? = null
|
||||
private var connectionTimer: Runnable? = null
|
||||
private var isUserDisconnect = false
|
||||
|
||||
val discoveredDevices = mutableStateListOf<BleDevice>()
|
||||
val isScanning = mutableStateOf(false)
|
||||
val isConnected = mutableStateOf(false)
|
||||
val connectedDeviceName = mutableStateOf("")
|
||||
val batteryLevel = mutableStateOf(0)
|
||||
val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
|
||||
val isServiceReady = mutableStateOf(false)
|
||||
val isReconnecting = mutableStateOf(false)
|
||||
val connectionError = mutableStateOf<String?>(null)
|
||||
|
||||
// Callbacks
|
||||
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
|
||||
var onNirsPowerOnReceived: (() -> Unit)? = null
|
||||
var onNirsSensorActivated: (() -> Unit)? = null
|
||||
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
|
||||
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
|
||||
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
|
||||
var onUnexpectedDisconnect: (() -> Unit)? = null
|
||||
var onReconnectionFailed: (() -> Unit)? = null
|
||||
|
||||
val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true
|
||||
|
||||
// Scanning
|
||||
fun startScan() {
|
||||
if (bluetoothAdapter?.isEnabled != true) {
|
||||
bluetoothEnabled.value = false
|
||||
return
|
||||
}
|
||||
bluetoothEnabled.value = true
|
||||
discoveredDevices.clear()
|
||||
isScanning.value = true
|
||||
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
|
||||
|
||||
if (bluetoothLeScanner == null) {
|
||||
Log.e(TAG, "BluetoothLeScanner is null")
|
||||
isScanning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
try {
|
||||
bluetoothLeScanner?.startScan(null, settings, scanCallback)
|
||||
Log.d(TAG, "BLE scan started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start scan: ${e.message}")
|
||||
isScanning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
scanTimer?.let { handler.removeCallbacks(it) }
|
||||
val timeout = Runnable { stopScan() }
|
||||
scanTimer = timeout
|
||||
handler.postDelayed(timeout, 15000)
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
try {
|
||||
bluetoothLeScanner?.stopScan(scanCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to stop scan: ${e.message}")
|
||||
}
|
||||
isScanning.value = false
|
||||
scanTimer?.let { handler.removeCallbacks(it) }
|
||||
scanTimer = null
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val name = try { device.name } catch (_: Exception) { null } ?: return
|
||||
if (name.isEmpty()) return
|
||||
|
||||
handler.post {
|
||||
val existing = discoveredDevices.indexOfFirst { it.address == device.address }
|
||||
if (existing >= 0) {
|
||||
discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi)
|
||||
} else {
|
||||
discoveredDevices.add(BleDevice(device, result.rssi))
|
||||
Log.d(TAG, "Discovered: $name (${device.address}) rssi=${result.rssi}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "Scan failed with error code: $errorCode")
|
||||
handler.post {
|
||||
isScanning.value = false
|
||||
connectionError.value = "Scan failed (error $errorCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection
|
||||
fun connect(bleDevice: BleDevice) {
|
||||
stopScan()
|
||||
Log.d(TAG, "Connecting to ${bleDevice.name} (${bleDevice.address})")
|
||||
|
||||
// Clean up any existing GATT connection
|
||||
bluetoothGatt?.let { gatt ->
|
||||
Log.d(TAG, "Closing existing GATT connection")
|
||||
try {
|
||||
gatt.disconnect()
|
||||
gatt.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error closing old GATT: ${e.message}")
|
||||
}
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
}
|
||||
|
||||
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
|
||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
||||
|
||||
connectionError.value = null
|
||||
|
||||
try {
|
||||
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
Log.d(TAG, "connectGatt called, gatt=${bluetoothGatt != null}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connectGatt failed: ${e.message}")
|
||||
handler.post {
|
||||
connectionError.value = "Connection failed: ${e.message}"
|
||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Connection timeout
|
||||
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||
val timeout = Runnable {
|
||||
if (!isConnected.value) {
|
||||
Log.w(TAG, "Connection timeout")
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try {
|
||||
gatt.disconnect()
|
||||
gatt.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
bluetoothGatt = null
|
||||
connectionError.value = "Connection timed out. Try again."
|
||||
if (idx >= 0 && idx < discoveredDevices.size) {
|
||||
discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
connectionTimer = timeout
|
||||
handler.postDelayed(timeout, 10000)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
isUserDisconnect = true
|
||||
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||
connectionTimer = null
|
||||
bluetoothGatt?.disconnect()
|
||||
}
|
||||
|
||||
// Reconnection
|
||||
fun reconnectToLastDevice(lastDeviceAddress: String?) {
|
||||
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
|
||||
onReconnectionFailed?.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up existing
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
|
||||
bluetoothGatt = null
|
||||
}
|
||||
|
||||
isReconnecting.value = true
|
||||
val device = bluetoothAdapter.getRemoteDevice(lastDeviceAddress)
|
||||
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
|
||||
handler.postDelayed({
|
||||
if (isReconnecting.value) {
|
||||
isReconnecting.value = false
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
|
||||
}
|
||||
bluetoothGatt = null
|
||||
onReconnectionFailed?.invoke()
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
// Send
|
||||
fun sendRaw(data: ByteArray) {
|
||||
val characteristic = txCharacteristic ?: return
|
||||
val gatt = bluetoothGatt ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = data
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeCharacteristic(characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// Piezo Commands
|
||||
fun sendPiezoPowerOn(freqOption: Int, cycles: Int) {
|
||||
sendRaw(CRC16.buildCommand("mpa", intArrayOf(freqOption, cycles)))
|
||||
}
|
||||
|
||||
fun sendPiezoStop() {
|
||||
sendRaw(CRC16.buildCommand("mps"))
|
||||
}
|
||||
|
||||
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
|
||||
sendRaw(CRC16.buildCommand("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
|
||||
}
|
||||
|
||||
// NIRS Commands
|
||||
fun sendNirsPowerOn() {
|
||||
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
|
||||
}
|
||||
|
||||
fun sendMqqQuery() {
|
||||
sendRaw(CRC16.buildCommand("mqq"))
|
||||
}
|
||||
|
||||
fun sendMagQuery() {
|
||||
sendRaw(CRC16.buildCommand("mag"))
|
||||
}
|
||||
|
||||
fun sendMcjQuery() {
|
||||
sendRaw(CRC16.buildCommand("mcj"))
|
||||
}
|
||||
|
||||
fun sendBatteryQuery() {
|
||||
sendRaw(CRC16.buildCommand("msn", intArrayOf(0)))
|
||||
}
|
||||
|
||||
fun startBatteryPolling() {
|
||||
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||
handler.postDelayed({ sendBatteryQuery() }, 1000)
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
sendBatteryQuery()
|
||||
handler.postDelayed(this, 30000)
|
||||
}
|
||||
}
|
||||
batteryTimer = poll
|
||||
handler.postDelayed(poll, 31000)
|
||||
}
|
||||
|
||||
private fun stopBatteryPolling() {
|
||||
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||
batteryTimer = null
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
Log.d(TAG, "onConnectionStateChange: status=$status newState=$newState")
|
||||
handler.post {
|
||||
// Cancel connection timeout
|
||||
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||
connectionTimer = null
|
||||
|
||||
if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
|
||||
// Connection failed with error
|
||||
Log.e(TAG, "GATT connection failed with status: $status")
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
isConnected.value = false
|
||||
isServiceReady.value = false
|
||||
isReconnecting.value = false
|
||||
connectionError.value = "Connection failed (status $status). Try again."
|
||||
|
||||
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
||||
|
||||
gatt.close()
|
||||
return@post
|
||||
}
|
||||
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
Log.d(TAG, "Connected to ${gatt.device.address}")
|
||||
isReconnecting.value = false
|
||||
isUserDisconnect = false
|
||||
connectionError.value = null
|
||||
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = true, isConnecting = false)
|
||||
isConnected.value = true
|
||||
connectedDeviceName.value = try { gatt.device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
||||
|
||||
// Save last connected device address
|
||||
try {
|
||||
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("last_device_address", gatt.device.address).apply()
|
||||
} catch (_: Exception) {}
|
||||
|
||||
gatt.discoverServices()
|
||||
startBatteryPolling()
|
||||
onConnectionStateChanged?.invoke(true)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.d(TAG, "Disconnected from ${gatt.device.address}")
|
||||
stopBatteryPolling()
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
isConnected.value = false
|
||||
isServiceReady.value = false
|
||||
connectedDeviceName.value = ""
|
||||
batteryLevel.value = 0
|
||||
|
||||
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
||||
|
||||
onConnectionStateChanged?.invoke(false)
|
||||
|
||||
if (!isUserDisconnect) {
|
||||
onUnexpectedDisconnect?.invoke()
|
||||
}
|
||||
isUserDisconnect = false
|
||||
gatt.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
Log.d(TAG, "onServicesDiscovered: status=$status")
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
handler.post { connectionError.value = "Service discovery failed (status $status)" }
|
||||
return
|
||||
}
|
||||
val service = gatt.getService(SERVICE_UUID)
|
||||
if (service == null) {
|
||||
Log.e(TAG, "NUS service not found!")
|
||||
handler.post { connectionError.value = "Device service not found" }
|
||||
return
|
||||
}
|
||||
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
|
||||
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
|
||||
|
||||
Log.d(TAG, "TX=${txCharacteristic != null}, RX=${rxCharacteristic != null}")
|
||||
|
||||
rxCharacteristic?.let { rx ->
|
||||
gatt.setCharacteristicNotification(rx, true)
|
||||
val descriptor = rx.getDescriptor(CCCD_UUID)
|
||||
if (descriptor != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "CCCD descriptor not found for RX characteristic")
|
||||
}
|
||||
}
|
||||
|
||||
handler.post {
|
||||
if (txCharacteristic != null && rxCharacteristic != null) {
|
||||
isServiceReady.value = true
|
||||
Log.d(TAG, "Service ready!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (characteristic.uuid == RX_CHAR_UUID) {
|
||||
if (value.size < 4) return
|
||||
handler.post { processReceivedData(value) }
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
if (characteristic.uuid == RX_CHAR_UUID) {
|
||||
@Suppress("DEPRECATION")
|
||||
val data = characteristic.value ?: return
|
||||
if (data.size < 4) return
|
||||
handler.post { processReceivedData(data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processReceivedData(data: ByteArray) {
|
||||
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||||
Log.d(TAG, "Received: $prefix (${data.size} bytes)")
|
||||
when (prefix) {
|
||||
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
||||
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
||||
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
|
||||
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
|
||||
"rpa:" -> onPiezoDataReceived?.invoke(data)
|
||||
"rsn:" -> {
|
||||
if (data.size >= 6) {
|
||||
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
||||
batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user