Files
VesiscanClinicalAndroid/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt
T
dw.jang c6e84bd6c0 fix: watchdog heartbeat, 배터리 계산, placement 디바운스+방향 수정
BLE:
- watchdog idle heartbeat: 10초 무통신 시 IMU query로 keepalive
- 배터리: 3500-3700mV→0-5%, 3700-4100mV→5-100%, 증가 무시
- 배터리 표시: 아이콘→퍼센트 숫자 (색상 유지)

Placement:
- 힌트 디바운스: 같은 phase 2회 연속일 때만 변경 (깜빡임 방지)
- 방향 힌트 반전: CH3만 잡힘→아래로↓, CH0만 잡힘→위로↑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:27:32 +09:00

921 lines
36 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 com.example.medilightv2android.BuildConfig
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"
private inline fun logd(msg: () -> String) { if (BuildConfig.DEBUG) Log.d(TAG, msg()) }
private inline fun logw(msg: () -> String) { if (BuildConfig.DEBUG) Log.w(TAG, msg()) }
private inline fun loge(msg: () -> String) { if (BuildConfig.DEBUG) Log.e(TAG, msg()) }
@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())
val debugLogger = com.example.medilightv2android.services.BleDebugLogger.getInstance(context)
private var scanTimer: Runnable? = null
private var batteryTimer: Runnable? = null
private var connectionTimer: Runnable? = null
private var isUserDisconnect = false
var lastConnectedAddress: String? = null
private set
private var reconnectAttempt = 0
private val reconnectIntervalMs: Long = 3000
private var reconnectTimer: Runnable? = null
private var lastRxTimestamp: Long = 0
private var watchdogTimer: Runnable? = null
private val watchdogTimeoutMs: Long = 15000
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
var onImuReceived: ((ByteArray) -> Unit)? = null
var onMbbHeaderReceived: ((ByteArray) -> 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) {
loge { "BluetoothLeScanner is null" }
isScanning.value = false
return
}
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
try {
bluetoothLeScanner?.startScan(null, settings, scanCallback)
logd { "BLE scan started" }
} catch (e: Exception) {
loge { "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) {
logw { "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))
logd { "Discovered: $name (${device.address}) rssi=${result.rssi}" }
}
}
}
override fun onScanFailed(errorCode: Int) {
loge { "Scan failed with error code: $errorCode" }
handler.post {
isScanning.value = false
connectionError.value = "Scan failed (error $errorCode)"
}
}
}
// Connection
fun connect(bleDevice: BleDevice) {
stopScan()
cancelAutoReconnect()
reconnectScanCallback?.let { cb ->
try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
reconnectScanCallback = null
}
reconnectConnecting = false
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
// Clean up any existing GATT connection
bluetoothGatt?.let { gatt ->
logd { "Closing existing GATT connection" }
try {
gatt.disconnect()
gatt.close()
} catch (e: Exception) {
logw { "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)
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
} catch (e: Exception) {
loge { "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) {
logw { "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 autoConnectLastDevice() {
if (isConnected.value) return
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
val address = prefs.getString("last_device_address", null) ?: return
val name = prefs.getString("last_device_name", null) ?: return
debugLogger.info("AUTO_CONNECT attempting $name ($address)")
lastConnectedAddress = address
scheduleAutoReconnect()
}
fun disconnect() {
isUserDisconnect = true
cancelAutoReconnect()
stopWatchdog()
connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null
bluetoothGatt?.disconnect()
}
fun disconnectAndUnbond() {
isUserDisconnect = true
cancelAutoReconnect()
stopWatchdog()
stopBatteryPolling()
connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null
// 기기에 본딩 삭제 + 재부팅 명령 (msr?)
sendRaw(CRC16.buildCommandASCII("msr", " "))
debugLogger.info("TX msr? unbond & reboot device")
val device = bluetoothGatt?.device
// 기기가 재부팅되므로 약간 대기 후 GATT 정리
handler.postDelayed({
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
batteryLevel.value = 0
lastConnectedAddress = null
// Android 측 본딩 정보도 삭제
device?.let { dev ->
try {
val method = dev.javaClass.getMethod("removeBond")
method.invoke(dev)
logd { "removeBond called for ${dev.address}" }
} catch (e: Exception) {
logw { "removeBond failed: ${e.message}" }
}
}
// 저장된 기기 정보 삭제
try {
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
} catch (_: Exception) {}
debugLogger.info("DEVICE_UNREGISTERED")
}, 300)
}
// Reconnection
fun reconnectToLastDevice(lastDeviceAddress: String?) {
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
onReconnectionFailed?.invoke()
return
}
// Stop any active reconnect scan first
reconnectScanCallback?.let { cb ->
try { bluetoothAdapter.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
reconnectScanCallback = null
}
reconnectConnecting = false
// 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
val gatt = bluetoothGatt
if (characteristic == null) {
loge { "sendRaw: txCharacteristic is null!" }
return
}
if (gatt == null) {
loge { "sendRaw: bluetoothGatt is null!" }
return
}
val cmdPreview = if (data.size >= 4) String(data, 0, 3, Charsets.US_ASCII) else "?"
val cmdDetail = when (cmdPreview) {
"maa" -> "6ch measure"
"mpa" -> "piezo power ON"
"mps" -> "piezo stop"
"mec" -> "single burst"
"msn" -> "battery query"
"mta" -> if (data.size > 4 && data[4].toInt() == 1) "NIRS power ON" else "NIRS power OFF"
"mqq" -> "NIRS activate"
"mag" -> "NIRS gain query"
"mcj" -> "NIRS MCJ query"
"mid" -> "device info query"
"mls" -> "LED mode ${if (data.size > 4) data[4].toInt() and 0xFF else "?"}"
"msp" -> "IMU query"
"mbb" -> "full measurement (battery+IMU+temp+6ch)"
else -> ""
}
logd { "sendRaw: $cmdPreview (${data.size} bytes)" }
debugLogger.tx("$cmdPreview $cmdDetail", data.size)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
logd { "sendRaw result: $result" }
} else {
@Suppress("DEPRECATION")
characteristic.value = data
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@Suppress("DEPRECATION")
val ok = gatt.writeCharacteristic(characteristic)
logd { "sendRaw success: $ok" }
}
}
// Piezo Commands
val piezoCollector = PiezoPacketCollector()
// ── Piezo Commands (테스터 exe와 동일 포맷: 파라미터=BE, 빈 파라미터=공백) ──
fun sendPiezoPowerOn(freqOption: Int = 2, cycles: Int = 5) {
sendRaw(CRC16.buildCommandBE("mpa", intArrayOf(freqOption, cycles)))
}
fun sendPiezoStop() {
sendRaw(CRC16.buildCommandASCII("mps", " "))
}
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
sendRaw(CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
}
fun sendAllChannels(mode: Int = 0) {
piezoCollector.startMultiChannel(6)
sendRaw(CRC16.buildCommandASCII("maa", " "))
}
// ── NIRS Commands ──
fun sendNirsPowerOn() {
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(1)))
}
fun sendNirsPowerOff() {
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(0)))
}
fun sendMqqQuery() {
sendRaw(CRC16.buildCommandASCII("mqq", " "))
}
fun sendMagQuery() {
sendRaw(CRC16.buildCommandASCII("mag", " "))
}
fun sendMcjQuery() {
sendRaw(CRC16.buildCommandASCII("mcj", " "))
}
// ── Common Commands ──
fun sendBatteryQuery() {
sendRaw(CRC16.buildCommandBE("msn", intArrayOf(0)))
}
fun sendDeviceInfoQuery() {
sendRaw(CRC16.buildCommandASCII("mid", " "))
}
fun sendLedMode(state: Int) {
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
}
fun sendImuQuery() {
sendRaw(CRC16.buildCommandASCII("msp", " "))
}
/** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */
fun sendFullMeasurement() {
piezoCollector.startMultiChannel(6)
sendRaw(CRC16.buildCommandASCII("mbb", " "))
}
/** 6채널 측정만 (maa) */
fun sendChannelsOnly() {
piezoCollector.startMultiChannel(6)
sendRaw(CRC16.buildCommandASCII("maa", " "))
}
fun startBatteryPolling() {
sendBatteryQuery() // 연결 시 1회만
}
fun stopBatteryPolling() {
batteryTimer?.let { handler.removeCallbacks(it) }
batteryTimer = null
}
// ── Watchdog (zombie connection detection, runs on separate thread) ──
private var watchdogJob: Thread? = null
@Volatile private var watchdogRunning = false
private fun startWatchdog() {
stopWatchdog()
lastRxTimestamp = System.currentTimeMillis()
watchdogRunning = true
debugLogger.info("WATCHDOG_STARTED timeout=${watchdogTimeoutMs}ms")
watchdogJob = Thread({
while (watchdogRunning) {
try { Thread.sleep(5000) } catch (_: InterruptedException) { break }
if (!watchdogRunning) break
if (!isConnected.value) break
val silenceMs = System.currentTimeMillis() - lastRxTimestamp
// Idle 10초 이상이면 IMU query로 heartbeat
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs) {
handler.post { sendImuQuery() }
}
if (silenceMs > watchdogTimeoutMs) {
debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect")
watchdogRunning = false
try {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
} catch (_: Exception) {}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
handler.post {
stopBatteryPolling()
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
}
break
}
}
}, "BLE-Watchdog").also { it.isDaemon = true; it.start() }
}
private fun stopWatchdog() {
watchdogRunning = false
watchdogJob?.interrupt()
watchdogJob = null
}
private fun forceDisconnectAndReconnect() {
stopBatteryPolling()
stopWatchdog()
isConnected.value = false
isServiceReady.value = false
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
}
// ── Auto Reconnect ──
private fun scheduleAutoReconnect() {
logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${reconnectIntervalMs}ms" }
debugLogger.reconnecting(reconnectAttempt + 1, -1, reconnectIntervalMs)
isReconnecting.value = true
cancelReconnectTimer()
val timer = Runnable { attemptAutoReconnect() }
reconnectTimer = timer
handler.postDelayed(timer, reconnectIntervalMs)
}
private var reconnectScanCallback: ScanCallback? = null
@Volatile private var reconnectConnecting = false
private fun attemptAutoReconnect() {
val targetAddress = lastConnectedAddress ?: return
val targetName = debugLogger.deviceName
if (bluetoothAdapter?.isEnabled != true) {
onReconnectionFailed?.invoke()
isReconnecting.value = false
return
}
reconnectAttempt++
reconnectConnecting = false
logd { "Auto-reconnect #$reconnectAttempt: scanning for $targetName ($targetAddress)" }
debugLogger.info("RECONNECT_SCAN #$reconnectAttempt scanning for $targetName")
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
bluetoothGatt = null
}
// Fresh scan to find the device again
val scanner = bluetoothAdapter.bluetoothLeScanner
if (scanner == null) {
scheduleAutoReconnect()
return
}
// Stop any previous reconnect scan
reconnectScanCallback?.let {
try { scanner.stopScan(it) } catch (_: Exception) {}
}
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (reconnectConnecting) return
val name = try { result.device.name ?: "" } catch (_: Exception) { "" }
val address = result.device.address
if (address == targetAddress || (targetName.isNotEmpty() && name == targetName)) {
reconnectConnecting = true
logd { "Reconnect scan found: $name ($address)" }
debugLogger.info("RECONNECT_FOUND $name ($address) RSSI=${result.rssi}")
try { scanner.stopScan(this) } catch (_: Exception) {}
reconnectScanCallback = null
bluetoothGatt = result.device.connectGatt(
context, false, gattCallback, BluetoothDevice.TRANSPORT_LE
)
}
}
}
reconnectScanCallback = callback
try {
scanner.startScan(callback)
} catch (e: Exception) {
debugLogger.error("RECONNECT_SCAN_FAIL ${e.message}")
scheduleAutoReconnect()
return
}
// Stop scan after 8 seconds → retry next attempt
handler.postDelayed({
try { scanner.stopScan(callback) } catch (_: Exception) {}
reconnectScanCallback = null
if (!isConnected.value) {
debugLogger.warn("RECONNECT_SCAN_TIMEOUT #$reconnectAttempt — device not found, retrying...")
scheduleAutoReconnect()
}
}, 8000)
}
private fun cancelReconnectTimer() {
reconnectTimer?.let { handler.removeCallbacks(it) }
reconnectTimer = null
}
fun cancelAutoReconnect() {
cancelReconnectTimer()
reconnectAttempt = 0
isReconnecting.value = false
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
logd { "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) {
val wasConnected = isConnected.value
loge { "GATT error: status=$status newState=$newState wasConnected=$wasConnected" }
debugLogger.error("GATT_ERROR status=$status newState=$newState wasConnected=$wasConnected")
stopBatteryPolling()
stopWatchdog()
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
gatt.close()
if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) {
debugLogger.disconnected("gatt_error", status)
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
} else if (reconnectAttempt > 0 && lastConnectedAddress != null) {
scheduleAutoReconnect()
} else {
isReconnecting.value = false
connectionError.value = "Connection failed (status $status). Try again."
}
return@post
}
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
logd { "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" }
// Auto-detect device preset + endian from name
com.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
piezoCollector.forceBigEndian = connectedDeviceName.value.startsWith("VBT")
logd { "Device preset: ${com.example.medilightv2android.managers.PiezoHW.presetName}, endian: ${if (piezoCollector.forceBigEndian) "BE" else "LE"} (from '${connectedDeviceName.value}')" }
lastConnectedAddress = gatt.device.address
reconnectAttempt = 0
cancelReconnectTimer()
debugLogger.connected(connectedDeviceName.value, gatt.device.address)
try {
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
prefs.edit()
.putString("last_device_address", gatt.device.address)
.putString("last_device_name", connectedDeviceName.value)
.apply()
} catch (_: Exception) {}
gatt.discoverServices()
onConnectionStateChanged?.invoke(true)
}
BluetoothProfile.STATE_DISCONNECTED -> {
logd { "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 && lastConnectedAddress != null) {
debugLogger.disconnected("unexpected", status)
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
}
isUserDisconnect = false
gatt.close()
}
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
logd { "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) {
loge { "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)
logd { "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 {
logw { "CCCD descriptor not found for RX characteristic" }
handler.post {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
startBatteryPolling()
startWatchdog()
logd { "Service ready (no CCCD)" }
}
}
}
} ?: run {
handler.post {
if (txCharacteristic != null) {
isServiceReady.value = true
startBatteryPolling()
startWatchdog()
logd { "Service ready (no RX)" }
}
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" }
if (descriptor.uuid == CCCD_UUID) {
handler.post {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
startBatteryPolling()
startWatchdog()
sendDeviceInfoQuery()
logd { "Service ready! (CCCD written)" }
}
} else {
loge { "CCCD write failed with status: $status" }
connectionError.value = "Notification setup failed (status $status)"
}
}
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
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 (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) {
@Suppress("DEPRECATION")
val data = characteristic.value ?: return
if (data.size < 4) return
handler.post { processReceivedData(data) }
}
}
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
handler.post { debugLogger.rssi(rssi) }
}
}
}
private fun processReceivedData(data: ByteArray) {
lastRxTimestamp = System.currentTimeMillis()
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
logd { "Received: $prefix (${data.size} bytes)" }
when (prefix) {
"rta:", "rta!" -> {
debugLogger.rx("rta", data.size, "piezo power ON")
onNirsPowerOnReceived?.invoke()
}
"rsh:", "rsh!", "rqq:", "rqq!" -> {
debugLogger.rx(prefix.take(3), data.size, "sensor activated")
onNirsSensorActivated?.invoke()
}
"rag:", "rag!" -> {
debugLogger.rx("rag", data.size, "gain calibration (${(data.size - 6) / 2} values)")
onNirsMagReceived?.invoke(data)
}
"rcj:", "rcj!" -> {
debugLogger.rx("rcj", data.size, "MCJ packet")
onNirsMcjReceived?.invoke(data)
}
"rpa:" -> {
debugLogger.rx("rpa", data.size, "piezo ack")
onPiezoDataReceived?.invoke(data)
}
"rer:" -> {
debugLogger.rx("rer", data.size, "preliminary header")
}
"reb:" -> {
val samples = (data.size - 8) / 2
debugLogger.rx("reb", data.size, "${samples}samples")
piezoCollector.addPacket(data)
}
"red:" -> {
debugLogger.rx("red", data.size, "continuation")
piezoCollector.addPacket(data)
}
"ree:", "raa:" -> {
val tag = prefix.take(3)
debugLogger.rx(tag, data.size, if (tag == "raa") "all-ch complete" else "single-ch end")
piezoCollector.addPacket(data)
}
"rsn:" -> {
if (data.size >= 6) {
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
val pct = when {
millivolts <= 3500 -> 0
millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200).coerceIn(0, 5)
else -> (5 + (millivolts - 3700) * 95 / 400).coerceIn(5, 100)
}
if (pct <= batteryLevel.value || batteryLevel.value == 0) {
batteryLevel.value = pct
}
debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)")
}
}
"rid:" -> {
val info = if (data.size > 4) String(data, 4, data.size - 4, Charsets.US_ASCII).trim() else "?"
debugLogger.rx("rid", data.size, "device_info=$info")
debugLogger.info("DEVICE_INFO $info")
}
"rls:" -> {
val state = if (data.size >= 5) data[4].toInt() and 0xFF else -1
debugLogger.rx("rls", data.size, "led_state=$state")
}
"rxs:" -> {
val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?"
debugLogger.rx("rxs", data.size, "cmd_not_supported: $echoCmd")
}
"rbb:" -> {
debugLogger.rx("rbb", data.size, "full measurement header (battery+IMU+temp)")
onMbbHeaderReceived?.invoke(data)
piezoCollector.addPacket(data)
}
"rsp:" -> {
if (data.size >= 16) {
debugLogger.rx("rsp", data.size, "IMU data")
}
onImuReceived?.invoke(data)
}
else -> {
val hex = data.take(8).joinToString(" ") { "%02X".format(it) }
debugLogger.rx("???", data.size, "unknown prefix='$prefix' hex=$hex")
logw { "Unknown prefix: '$prefix' hex=$hex" }
}
}
}
}