Files
VesiscanClinicalAndroid/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt
T
dw.jang e370d9c702 PERF: 측정 사이클 간격 1500/1000ms → 600ms 단축
MTU 247 + CONN_PRIORITY HIGH 적용 후 한 사이클 응답이 ~280~420ms로 단축됨
(이전 ~1.2~1.4s 대비 약 4배). 응답 시간 대비 과도하게 보수적이었던
throttle/loop delay를 일괄 단축.

변경:
- BleManager.canSendMaa() 시간 게이트 1500ms → 600ms (응답 평균 + 안전 마진 ~200ms)
- BleManager.canSendMaa() FORCE 게이트 5000ms → 3000ms (응답 빨라진 만큼 deadlock 감지도 빠르게)
- PiezoMonitor Auto loop delay 1500ms → 600ms
- PlacementGuide continuous scan loop delay 1000ms → 600ms

state-based gate(!isComplete)가 안전망 역할을 하므로 응답이 늦어지면
자동으로 차단되어 데이터 유실 위험은 그대로. 정상 경우엔 사이클당 ~0.6~0.7s로
실시간 모니터링 반응성 2배 향상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:34:51 +09:00

1002 lines
41 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
@Volatile private var bluetoothGatt: BluetoothGatt? = null
@Volatile private var txCharacteristic: BluetoothGattCharacteristic? = null
@Volatile 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
@Volatile 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 isBonded: Boolean
@android.annotation.SuppressLint("MissingPermission")
get() = bluetoothGatt?.device?.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED
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()
com.example.medilightv2android.services.BleForegroundService.stop(context)
}
fun disconnectAndUnbond() {
isUserDisconnect = true
cancelAutoReconnect()
stopWatchdog()
stopBatteryPolling()
com.example.medilightv2android.services.BleForegroundService.stop(context)
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): Boolean {
if (!canSendMaa("sendAllChannels")) return false
lastMaaSentMs = System.currentTimeMillis()
piezoCollector.startMultiChannel(6)
sendRaw(CRC16.buildCommandASCII("maa", " "))
return true
}
// ── 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) 송신 가능 여부:
* (A) 이전 응답이 아직 진행 중(isMultiChannel && !isComplete)이면 차단.
* 단 3초 넘게 응답이 안 오면 강제 통과(영영 deadlock 방지).
* (B) 마지막 송신으로부터 600ms 미만이면 차단(펌웨어 burst 방어).
*
* FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 조합에서 한 사이클 응답이
* 약 280~420ms이므로, 600ms = 응답 평균 + 안전 마진 ~200ms.
*/
private var lastMaaSentMs = 0L
private fun canSendMaa(caller: String): Boolean {
val now = System.currentTimeMillis()
val collectorBusy = piezoCollector.isMultiChannel && !piezoCollector.isComplete
val sinceLast = now - lastMaaSentMs
if (collectorBusy) {
if (sinceLast < 3000) {
android.util.Log.w("BleManager", "maa BUSY [$caller] — prev response in progress (${sinceLast}ms)")
return false
}
android.util.Log.w("BleManager", "maa FORCE [$caller] — prev incomplete after ${sinceLast}ms, abandoning")
}
if (sinceLast < 600) {
android.util.Log.w("BleManager", "maa THROTTLED [$caller] — ${sinceLast}ms since last")
return false
}
return true
}
fun sendChannelsOnly(): Boolean {
if (!canSendMaa("sendChannelsOnly")) return false
lastMaaSentMs = System.currentTimeMillis()
piezoCollector.startMultiChannel(6)
sendRaw(CRC16.buildCommandASCII("maa", " "))
return true
}
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) {}
// MTU 247 요청 (reb: 208B 패킷 드롭 방지)
// onMtuChanged 콜백에서 discoverServices 호출
if (!gatt.requestMtu(247)) {
gatt.discoverServices() // MTU 요청 실패 시 바로 진행
}
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 onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
logd { "MTU changed: $mtu (status=$status)" }
handler.post { debugLogger.info("MTU=$mtu") }
gatt.discoverServices()
}
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
// Connection priority HIGH 요청: peripheral이 수락하면 interval ~15ms로 협상되어
// Central Link Layer ACK 속도 ↑ → FW SoftDevice TX queue 포화 빈도 ↓
val priorityOk = try {
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
} catch (_: Exception) { false }
logd { "requestConnectionPriority(HIGH) → $priorityOk" }
debugLogger.info("CONN_PRIORITY HIGH requested (ok=$priorityOk)")
com.example.medilightv2android.services.AdcCsvLogger.newSession()
com.example.medilightv2android.services.BleForegroundService.start(context)
startBatteryPolling()
startWatchdog()
sendDeviceInfoQuery()
logd { "Service ready! (CCCD written)" }
}
} else {
loge { "CCCD write failed with status: $status — retrying in 500ms" }
// GATT 133 등 일시적 오류 → 500ms 후 재시도
handler.postDelayed({
try {
val rx = rxCharacteristic
if (rx != null) {
gatt.setCharacteristicNotification(rx, true)
val cccd = rx.getDescriptor(CCCD_UUID)
if (cccd != null) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(cccd)
}
logd { "CCCD retry sent" }
}
}
} catch (e: Exception) {
connectionError.value = "Notification setup failed (status $status)"
}
}, 500)
}
}
}
}
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
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
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" }
}
}
}
}