package com.medithings.vesiscan.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.medithings.vesiscan.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.medithings.vesiscan.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 // 2026-07-07: 15 → 25초. 실측 로그 (2026-07-07 10:43~10:44) 에서 재연결 후 첫 RX 가 // 14초 지연 후 도착하는 케이스 확인. Peripheral / OS BLE 스택의 좀비 회복 시간을 // 허용하기 위해 timeout 여유 확보. 15초로는 회복 직전에 forced reconnect 발생 → // 무한 재연결 루프. 25초면 대부분 회복 케이스 커버 가능. private val watchdogTimeoutMs: Long = 25000 // 2026-07-07 fix: disconnect 시 취소 위해 Runnable 참조 유지. // fwFallback: onDescriptorWrite 성공 후 4초 mfv? fallback. // cccdRetry: onDescriptorWrite 실패 후 500ms retry. private var fwFallbackTimer: Runnable? = null private var cccdRetryTimer: Runnable? = null val discoveredDevices = mutableStateListOf() 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" /** 마지막으로 의미있는 BLE RX (reb 등) 가 도착한 시각 ms. * FirmwareWarningBanner false-positive 차단용. */ val lastBleRxAt = mutableStateOf(0L) /** "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(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.medithings.vesiscan.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.medithings.vesiscan.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.medithings.vesiscan.services.BleForegroundService.stop(context) } fun disconnectAndUnbond() { isUserDisconnect = true cancelAutoReconnect() stopWatchdog() stopBatteryPolling() com.medithings.vesiscan.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.medithings.vesiscan.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 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low "mls" -> "LED mode ${ if (data.size > 5) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else "?" }" "msp" -> "IMU query" "mim" -> "IMU FIFO (15 samples)" "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", " ")) // 2026-07-08: 3초 timeout. raa 응답이 오면 취소 (아래 processReceivedData 참조). // Timeout 시 lastMtbTimeoutAt 갱신 + consecutiveMtbTimeouts 증가 → 상위 UI 안내. mtbTimeoutRunnable?.let { handler.removeCallbacks(it) } val to = Runnable { if (isMtbBusy) { lastMtbTimeoutAt.value = System.currentTimeMillis() consecutiveMtbTimeouts.value = consecutiveMtbTimeouts.value + 1 debugLogger.warn("MTB_TIMEOUT after ${MTB_TIMEOUT_MS}ms (consecutive=${consecutiveMtbTimeouts.value})") // collector 상태 해제 — 다음 mtb 는 정상적으로 시작 가능 piezoCollector.reset() imuCollector.reset() } } mtbTimeoutRunnable = to handler.postDelayed(to, MTB_TIMEOUT_MS) 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))) } // 2026-07-08: sendImuQuery() (msp) 완전 제거. 신 firmware 는 mim (FIFO 15 sample) 만 사용. // Watchdog heartbeat 도 sendImuFifoQuery (mim) 로 통일. // ※ debugLogger 명령 매핑 (msp → "IMU query") 은 legacy 응답 (rsp:) 파싱용으로 유지. /** * mtb 응답 진행 중 여부 — Battery/IMU 폴링이 mtb 응답 stream (reb×6 + raa + rim) 중간에 * 끼어들면 firmware GATT queue 가 꼬여 응답 실종 → freeze 유발. * 이 property 를 batteryTimer / mim polling / watchdog heartbeat 앞에서 확인해 skip. */ val isMtbBusy: Boolean get() = piezoCollector.isMultiChannel && !piezoCollector.isComplete /** * mtb 응답 3초 timeout 후 갱신되는 마지막 timeout 시각. 상위 UI 가 관찰해서 * "기기 응답 지연" 배너 표시. 0 = 아직 timeout 없음. */ val lastMtbTimeoutAt = mutableStateOf(0L) /** mtb 연속 timeout 카운터. 3회 이상 시 재연결 유도. */ val consecutiveMtbTimeouts = mutableStateOf(0) private var mtbTimeoutRunnable: Runnable? = null private val MTB_TIMEOUT_MS = 3000L /** * IMU FIFO 폴링 (2026-07-03 펌웨어 신규 mim?): * - piezo burst 없음 (무음) * - IMU 15 sample (300ms window) 를 rim: 프레임으로 반환 * - 응답 스키마는 mtb? 의 rim: 와 동일 → imuCollector.parseRim + onComplete 재사용 * idle 폴링에서 walking detector 의 sliding window 를 채우기 위해 사용. * mtb 진행 중에는 imuCollector.reset() 이 mtb 의 rim 을 파괴하므로 caller 가 gating 필수. */ fun sendImuFifoQuery() { imuCollector.reset() sendRaw(CRC16.buildCommandASCII("mim", " ")) } /** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */ fun sendFullMeasurement() { piezoCollector.startMultiChannel(6) sendRaw(CRC16.buildCommandASCII("mbb", " ")) } /** * 6채널 측정 명령(maa) 송신 가능 여부: * (A) 이전 응답이 아직 진행 중(isMultiChannel && !isComplete)이면 차단. * 단 3초 넘게 응답이 안 오면 강제 통과(영영 deadlock 방지). * (B) 마지막 송신으로부터 500ms 미만이면 차단(펌웨어 burst 방어). * * FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 조합에서 한 사이클 응답이 * 약 280~420ms이므로, 500ms = 응답 평균 + 안전 마진 ~80~220ms. */ 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 < 500) { 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)" } // 2026-07-08: mtb 응답 진행 중이면 skip — 다음 주기까지 대기 (queue 충돌 방지). if (!isMtbBusy) 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 // 2026-07-08: mtb 응답 진행 중이면 skip — 30초 주기라 다음 tick 에 잡힘. if (!isMtbBusy) 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. // 2026-07-08: sendImuQuery() (msp) → sendImuFifoQuery() (mim). msp 는 신 // firmware 에서 미지원. isMtbBusy 시 skip — mtb 응답 중간에 mim 이 끼어들면 // imuCollector.reset() 이 mtb 의 rim 을 파괴 + GATT queue 혼잡 유발. if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs && !isMtbBusy) { handler.post { if (!isMtbBusy) sendImuFifoQuery() } } if (silenceMs > watchdogTimeoutMs) { debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect") // 2026-07-07 fix: watchdog thread 에서 직접 GATT 정리 + characteristic // null 하면 UI thread 의 sendRaw 와 race — writeCharacteristic 이 이미 // close 중인 GATT 에 접근. 모든 정리를 UI thread (handler.post) 로 위임하여 // single-threaded 로 실행. watchdogRunning = false handler.post { try { bluetoothGatt?.disconnect() bluetoothGatt?.close() } catch (_: Exception) {} bluetoothGatt = null txCharacteristic = null rxCharacteristic = null fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } 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 } 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() // 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } 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.medithings.vesiscan.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value) piezoCollector.forceBigEndian = connectedDeviceName.value.startsWith("VBT") logd { "Device preset: ${com.medithings.vesiscan.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) // 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시 // KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등). try { com.medithings.vesiscan.services.KnownDeviceStore.add( context, gatt.device.address, connectedDeviceName.value ) } catch (_: Exception) {} // MTU 247 요청 (reb: 208B 패킷 드롭 방지) // onMtuChanged 콜백에서 discoverServices 호출 if (!gatt.requestMtu(247)) { gatt.discoverServices() // MTU 요청 실패 시 바로 진행 } // 2026-07-07 fix: onConnectionStateChanged(true) 를 여기서 호출하면 // TX/RX characteristic 이 아직 null (onServicesDiscovered 미실행) 이라 // 상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시작해도 // sendRaw 에서 silent drop → RX 실종 → watchdog timeout → forced reconnect // 좀비 세션의 원인. 콜백은 isServiceReady=true 시점 (onDescriptorWrite) // 으로 이동. } BluetoothProfile.STATE_DISCONNECTED -> { logd { "Disconnected from ${gatt.device.address}" } isConnecting.value = false stopBatteryPolling() // 2026-07-07 fix: disconnect 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } 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 onConnectionStateChanged?.invoke(true) startBatteryPolling() startWatchdog() logd { "Service ready (no CCCD)" } } } } } ?: run { handler.post { if (txCharacteristic != null) { isServiceReady.value = true onConnectionStateChanged?.invoke(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 // 2026-07-07 fix: 여기서 상위에 알림 — TX/RX 준비 완료 시점. 이전에는 // STATE_CONNECTED 즉시 호출해서 아직 characteristic null 인 상태로 // 상위가 TX 시도 → silent drop → 좀비 세션. onConnectionStateChanged?.invoke(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.medithings.vesiscan.services.AdcCsvLogger.newSession() com.medithings.vesiscan.services.BleForegroundService.start(context) startBatteryPolling() startWatchdog() sendDeviceInfoQuery() // 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성). // 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지. fwFallbackTimer?.let { handler.removeCallbacks(it) } val fwFallback = Runnable { if (firmwareVersion.value.isEmpty() && isConnected.value) { debugLogger.info("FW fallback: sending mfv?") sendFirmwareVersionQuery() } } fwFallbackTimer = fwFallback handler.postDelayed(fwFallback, 4_000) logd { "Service ready! (CCCD written)" } } } else { loge { "CCCD write failed with status: $status — retrying in 500ms" } // GATT 133 등 일시적 오류 → 500ms 후 재시도. // 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지. cccdRetryTimer?.let { handler.removeCallbacks(it) } val cccdRetry = Runnable { 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)" } } cccdRetryTimer = cccdRetry handler.postDelayed(cccdRetry, 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) { val now = System.currentTimeMillis() lastRxTimestamp = now // 2026-07-07 fix: lastBleRxAt 을 processReceivedData 진입점에서 무조건 갱신. // 이전에는 reb: (piezo 측정) 응답 케이스에만 세팅 → auto scan 꺼진 도넛차트 // idle 상태 (mim 폴링만) 에서는 rim 응답이 매초 오는데도 lastBleRxAt = 0. // FirmwareWarningBanner 의 deviceAlive 판정이 false → 어떤 이유로 timedOut=true // 되면 "기기 응답 없음" false-positive 배너 노출. lastBleRxAt.value = now // Raw BLE capture (디버그 — 사용자가 토글로 enable 했을 때만 writes) com.medithings.vesiscan.services.RawBleLogger.write(data) 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) lastBleRxAt.value = System.currentTimeMillis() // 기기 alive 마크 } "rec:" -> { // 저 MTU chunk (2026-07-21 FW+): tag 4 + ch_info 2 + off 2 + total 2 + chunk 2 + ADC + CRC 2 // → ADC bytes = size − 14. 첫/마지막 chunk 마다 이 케이스로 진입. 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 val off = if (data.size >= 8) ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) else -1 val total = if (data.size >= 10) ((data[8].toInt() and 0xFF) shl 8) or (data[9].toInt() and 0xFF) else -1 val chunk = if (data.size >= 12) ((data[10].toInt() and 0xFF) shl 8) or (data[11].toInt() and 0xFF) else -1 debugLogger.rx("rec", data.size, "session=$chSession ch=$chNum off=$off chunk=$chunk/$total") piezoCollector.addPacket(data) lastBleRxAt.value = System.currentTimeMillis() } "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) // 2026-07-08: mtb 응답 완료 → timeout runnable 취소 + 연속 실패 카운터 리셋. if (tag == "raa") { mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } if (consecutiveMtbTimeouts.value != 0) consecutiveMtbTimeouts.value = 0 } } "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") } "rbb:" -> { debugLogger.rx("rbb", data.size, "full measurement header (battery+IMU+temp)") onMbbHeaderReceived?.invoke(data) piezoCollector.addPacket(data) } "rsp:" -> { // msp 명령 응답 — IMU 데이터. format 불명확이라 defensive parsing. val hex = data.take(kotlin.math.min(data.size, 24)).joinToString(" ") { "%02X".format(it) } debugLogger.rx("rsp", data.size, "IMU data hex=$hex") android.util.Log.d("ImuDbg", "rsp RX len=${data.size} hex=$hex") imuCollector.parseRsp(data) onImuReceived?.invoke(data) } "rxs:" -> { val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?" android.util.Log.w("ImuDbg", "rxs cmd_not_supported: $echoCmd") debugLogger.rx("rxs", data.size, "cmd_not_supported: $echoCmd") } "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) } "ric:" -> { // 저 MTU IMU chunk (2026-07-21 FW+): tag 4 + off 2 + total 2 + chunk 2 + IMU 12·chunk + CRC 2 val off = if (data.size >= 6) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else -1 val total = if (data.size >= 8) ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) else -1 val chunk = if (data.size >= 10) ((data[8].toInt() and 0xFF) shl 8) or (data[9].toInt() and 0xFF) else -1 debugLogger.rx("ric", data.size, "IMU off=$off chunk=$chunk/$total") imuCollector.parseRic(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" } } } } }