Files
VesiscanClinicalAndroid/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt
T
dw.jang 2d32949a14 fix: 매 connect 마다 device-specific state 리셋
증상: 디바이스 A → B 로 스위치 시 firmware/battery/serial 이
이전 디바이스 값으로 잠깐 그대로 노출됨. STATE_DISCONNECTED
콜백이 안 떴거나 다른 디바이스로 즉시 스위치하는 경우.

원인: BleManager 가 singleton 이라 state 가 연결-연결 사이에
유지됨. 새 mid?/msn? 응답 받기 전까진 이전 디바이스 값이
보임 → 사용자가 잘못된 정보로 진단.

수정:
- connect(BleDevice) 진입부에 firmwareVersion / hardwareVersion
  / serialNumber / batteryLevel / connectedDeviceName /
  isServiceReady 모두 reset
- reconnectToLastDevice 도 동일 처리
- connectByAddress 는 connect 위임 → 자동 커버

연결 시작 ~ mid?/msn? 응답 도착 사이 잠깐 빈 값/0으로 보임 →
정확. 응답 도착하면 즉시 새 값 반영.
2026-06-12 11:14:38 +09:00

1222 lines
51 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"
/** 정상 측정에 필요한 최소 펌웨어 빌드 번호 (mtb? throttle/MTU 가정과 일치). */
const val MIN_FIRMWARE_BUILD = 118
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 }
}
/** Logger 같은 context-free 컴포넌트에서 BLE state 읽기용. instance 생성 후에만 non-null. */
val current: BleManager? get() = instance
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 batteryRetryTimer: Runnable? = null
private val BATTERY_POLL_INTERVAL_MS = 30_000L // 정상 polling 간격
private val BATTERY_RETRY_DELAY_MS = 3_000L // 첫 응답 재시도 지연
private val BATTERY_RETRY_MAX = 2 // 최대 재시도 횟수
private var connectionTimer: Runnable? = null
private var isUserDisconnect = false
var lastConnectedAddress: String? = null
private set
private var reconnectAttempt = 0
private val reconnectIntervalMs: Long = 3000
/** max retry — 10회 시도 후 포기 (≈ 8s scan × 10 + 간격, 약 100~120초). */
private val MAX_RECONNECT_ATTEMPTS = 10
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("")
// mid? → rid: 응답에서 파싱. 연결 직후 자동 채워짐.
val firmwareVersion = mutableStateOf("") // "VBTFW0116"
val hardwareVersion = mutableStateOf("") // "VBTHW0100"
val serialNumber = mutableStateOf("") // "VBT26040001"
/** "VBTFW0116" → 116, 형식 안 맞으면 null. */
fun parseFirmwareBuild(raw: String): Int? {
val m = Regex("(\\d+)$").find(raw) ?: return null
return m.groupValues[1].toIntOrNull()
}
/** 펌웨어 버전이 MIN_FIRMWARE_BUILD 미만이면 true. 모르거나(아직 미수신) 형식 안 맞으면 false. */
val isFirmwareOutdated: Boolean
get() {
val n = parseFirmwareBuild(firmwareVersion.value) ?: return false
return n < MIN_FIRMWARE_BUILD
}
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)
/** 새 페어링/재연결 진행 중 (connectGatt 호출 ~ STATE_CONNECTED 또는 실패까지).
* ClinicalHome 등에서 "BLE Connecting…" 표시용. */
val isConnecting = 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
}
// 이전 디바이스의 state 가 남아있는 걸 방지 — 매 connect 마다 fresh.
// 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확.
// (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응)
firmwareVersion.value = ""
hardwareVersion.value = ""
serialNumber.value = ""
batteryLevel.value = 0
connectedDeviceName.value = ""
isServiceReady.value = false
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
connectionError.value = null
isConnecting.value = true
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}" }
isConnecting.value = false
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)
}
/**
* 특정 주소의 known device 페어링 해제 — 현재 연결된 기기가 아니어도 사용 가능.
* 연결된 상태라면 일반 disconnectAndUnbond() 를 사용.
*/
fun unbondAndRemoveAddress(address: String) {
// 현재 연결된 기기를 해제하려는 경우
if (bluetoothGatt?.device?.address == address) {
disconnectAndUnbond()
return
}
// 연결 안 된 known device — OS bond 만 끊고 list 에서 제거
val adapter = bluetoothAdapter ?: return
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull()
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}" }
}
}
com.example.medilightv2android.services.KnownDeviceStore.remove(context, address)
debugLogger.info("DEVICE_UNREGISTERED $address")
}
/**
* 저장된 기기 주소로 직접 연결. 스캔 결과에 안 잡혀도 OS bond 가 있으면 바로 됨.
* KnownDeviceStore 에서 골라 호출.
*/
fun connectByAddress(address: String, name: String) {
val adapter = bluetoothAdapter ?: run {
connectionError.value = "Bluetooth not available"
return
}
val device = try {
adapter.getRemoteDevice(address)
} catch (e: IllegalArgumentException) {
connectionError.value = "Invalid device address"
return
}
connect(BleDevice(device = device, rssi = 0))
// discoveredDevices 에 없을 수 있으므로 name 을 별도로 저장 (기존 connect 가 onConnected 시점에 name 설정)
connectedDeviceName.value = name
}
/** 저장된 마지막 기기에 자동 연결 시도 */
fun autoConnectLastDevice() {
if (isConnected.value) return
val recent = com.example.medilightv2android.services.KnownDeviceStore.mostRecent(context) ?: return
debugLogger.info("AUTO_CONNECT attempting ${recent.name} (${recent.address})")
lastConnectedAddress = recent.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}" }
}
}
// 저장된 기기 정보 삭제 — known device list 에서도 제거
try {
device?.let { dev ->
com.example.medilightv2android.services.KnownDeviceStore.remove(context, dev.address)
}
} 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
}
// 이전 디바이스 state fresh (다른 디바이스로 스위치하는 reconnect 케이스 대비)
firmwareVersion.value = ""
hardwareVersion.value = ""
serialNumber.value = ""
batteryLevel.value = 0
connectedDeviceName.value = ""
isServiceReady.value = false
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()
// IMU rim: 응답 파서. mtb? 명령 시 채워짐. PlacementGuide/Clinical에서 콜백 등록.
val imuCollector = ImuPacketCollector()
// ── 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
}
/**
* 정렬 모드 측정 (mtb?) — Piezo 6채널 + IMU 동시 캡처.
* 응답: reb: × 6 + raa: + rim: (각각 piezoCollector / imuCollector가 처리)
*
* canSendMaa() 게이트는 maa와 공유 (lastMaaSentMs 같이 사용).
*/
fun sendMtb(): Boolean {
if (!canSendMaa("sendMtb")) return false
lastMaaSentMs = System.currentTimeMillis()
piezoCollector.startMultiChannel(6)
imuCollector.reset()
sendRaw(CRC16.buildCommandASCII("mtb", " "))
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() {
// mid?/mtb?/maa? 등 다른 단순 query와 동일한 ASCII " " 형식 — BE [0]은
// 펌웨어가 응답 안 함이 확인됨 (rsn: 자체가 안 옴, 2026-06-08).
sendRaw(CRC16.buildCommandASCII("msn", " "))
}
fun sendDeviceInfoQuery() {
sendRaw(CRC16.buildCommandASCII("mid", " "))
}
/** mfv? — FW Version only. mid? 응답 안 오는 펌웨어용 fallback. */
fun sendFirmwareVersionQuery() {
sendRaw(CRC16.buildCommandASCII("mfv", " "))
}
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
}
/**
* 배터리 polling 시작.
*
* 이전엔 "1회 송신만" 이었고, 그 한 번이 큐 경합/타이밍 문제로 손실되면 배터리가
* 영원히 0%로 표시되는 이슈가 있었음. 이제 세 단계로 보장:
* 1. 즉시 1회 송신 (best case)
* 2. 3초 내 응답 없으면(batteryLevel.value == 0) 최대 2회 재송신 (첫 응답 누락 보강)
* 3. 30초 주기 정상 polling (충전 반영 + 장기 추적)
*
* BLE 끊김 / 측정 시작 시 stopBatteryPolling()으로 정리.
*/
fun startBatteryPolling() {
stopBatteryPolling() // 기존 타이머 안전 정리
sendBatteryQuery() // 1단계: 즉시 1회
// 2단계: 첫 응답 재시도 — batteryLevel.value가 0인 동안 재송신
var retries = 0
val retryRunnable = object : Runnable {
override fun run() {
if (!isConnected.value) return
if (batteryLevel.value == 0 && retries < BATTERY_RETRY_MAX) {
retries++
logd { "battery query retry #$retries (no response yet)" }
sendBatteryQuery()
handler.postDelayed(this, BATTERY_RETRY_DELAY_MS)
}
}
}
batteryRetryTimer = retryRunnable
handler.postDelayed(retryRunnable, BATTERY_RETRY_DELAY_MS)
// 3단계: 30초 주기 polling
val pollRunnable = object : Runnable {
override fun run() {
if (!isConnected.value) return
sendBatteryQuery()
handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS)
}
}
batteryTimer = pollRunnable
handler.postDelayed(pollRunnable, BATTERY_POLL_INTERVAL_MS)
}
fun stopBatteryPolling() {
batteryTimer?.let { handler.removeCallbacks(it) }
batteryTimer = null
batteryRetryTimer?.let { handler.removeCallbacks(it) }
batteryRetryTimer = 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
}
// max 시도 가드 — 무한 루프 방지. 초과 시 사용자가 명시적으로 재시도하도록.
if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
debugLogger.warn("RECONNECT_GIVE_UP after ${reconnectAttempt} attempts — manual retry required")
isReconnecting.value = false
reconnectAttempt = 0
onReconnectionFailed?.invoke()
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
isConnecting.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)
// Clinical 흐름에서만 KnownDeviceStore 영속화. 일반 사용자는
// 매 세션 fresh scan + pair — 저장 0개.
if (com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow) {
try {
com.example.medilightv2android.services.KnownDeviceStore.add(
context, gatt.device.address, connectedDeviceName.value
)
} 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}" }
isConnecting.value = false
stopBatteryPolling()
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
firmwareVersion.value = ""
hardwareVersion.value = ""
serialNumber.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()
// 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성)
handler.postDelayed({
if (firmwareVersion.value.isEmpty() && isConnected.value) {
debugLogger.info("FW fallback: sending mfv?")
sendFirmwareVersionQuery()
}
}, 4_000)
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:" -> {
// 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10
val samples = ((data.size - 10) / 2).coerceAtLeast(0)
val chSession = if (data.size > 4) data[4].toInt() and 0xFF else -1
val chNum = if (data.size > 5) data[5].toInt() and 0xFF else -1
debugLogger.rx("reb", data.size, "session=$chSession ch=$chNum ${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:" -> {
// 명세: [tag 4B] [HW 12B] [Serial 12B] [FW 12B] [crc 2B] = 42B.
// 각 12B 필드는 ASCII 가변 길이 + NULL/공백/제어문자 padding.
// (구) whitespace split 방식은 padding 이 space 가 아닐 때 깨졌음.
fun field(offset: Int): String {
val end = (offset + 12).coerceAtMost(data.size - 2) // CRC 2B 앞까지
if (end <= offset) return ""
return String(data, offset, end - offset, Charsets.US_ASCII)
.replace(Regex("[\\x00-\\x1F\\x7F]"), "").trim()
}
val hw = field(4)
val sn = field(16)
val fw = field(28)
val info = "$hw $sn $fw".trim()
debugLogger.rx("rid", data.size, "device_info=$info")
debugLogger.info("DEVICE_INFO hw=$hw sn=$sn fw=$fw")
handler.post {
if (hw.isNotEmpty()) hardwareVersion.value = hw
if (fw.isNotEmpty()) firmwareVersion.value = fw
if (sn.isNotEmpty()) serialNumber.value = sn
}
debugLogger.deviceInfo(fw, hw, sn)
}
"rfv:" -> {
// 명세: [tag 4B] [FW ASCII N B] [crc 2B] — FW 만 별도 쿼리(mfv?) 응답.
// mid? 가 응답 안 하는 펌웨어 fallback 용.
if (data.size > 6) {
val fw = String(data, 4, data.size - 6, Charsets.US_ASCII)
.replace(Regex("[\\x00-\\x1F\\x7F]"), "").trim()
debugLogger.rx("rfv", data.size, "fw=$fw")
if (fw.isNotEmpty()) handler.post { firmwareVersion.value = fw }
}
}
"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)
}
"rim:" -> {
val num = if (data.size >= 6) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else 0
debugLogger.rx("rim", data.size, "IMU ${num} samples")
imuCollector.parseRim(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" }
}
}
}
}