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 rssiTimer: Runnable? = null private val RSSI_POLL_INTERVAL_MS = 2_000L // dev-mode UI polling 간격 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초). */ // 2026-08-04: 10 → 5 축소. 지속 실패 시 사용자에게 명확한 안내가 낫고 무한 시도로 // 상단 배너/로그 스팸 방지. 실패 후 사용자가 명시적으로 재시도 (앱 재시작 · 재연결). private val MAX_RECONNECT_ATTEMPTS = 5 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. // fwRetry: 2026-08-04 신규 · mid?/mfv? 초기 응답 실패 시 5초 간격 재시도 최대 3회. // cccdRetry: onDescriptorWrite 실패 후 500ms retry. private var fwFallbackTimer: Runnable? = null private var fwRetryTimer: Runnable? = null @Volatile private var fwRetryCount: Int = 0 private val FW_RETRY_MAX = 3 private val FW_RETRY_INTERVAL_MS = 5_000L /** * CONNECTED (CCCD write 성공) 후 첫 명령까지 대기 시간. * 2026-08-04: R100 목업 유닛이 boot 후 응답까지 9초 걸리는 케이스 관찰 (VBT0607R100 로그). * 첫 msn/mid 를 즉시 발사 → 3초 CMDQ timeout 3회 반복 → 사용자에게 노출. * 2초 지연 두면 FW startup 이 조금 여유 있을 때 첫 명령 통과 확률 상승. * 여전히 매우 느린 FW 는 재시도 로직 (scheduleFirmwareRetry) 이 커버. */ private val INITIAL_TX_DELAY_MS = 2_000L 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() } /** * 2026-08-05: FW 팀 스펙 (VBTFW 코드표) 반영. 두 포맷 통일 매핑. * * A. VBTFW MMNN → (M, 0, N) * 예: VBTFW0100 = 1.0.0, VBTFW0200 = 2.0.0, VBTFW0201 = 2.0.1 * B. MCUboot major.minor.patch[+build] → (major, minor, patch) * 예: 1.0.0+5 = 1.0.0 (build 5 는 major/minor/patch 비교와 무관) * * Returns Triple(major, minor, patch) · 파싱 실패 시 null. */ private fun parseFwSemver(raw: String): Triple? { // VBTFW / VB0FW / VBFW MMNN val vbt = Regex("""VB\d?FW(\d{2})(\d{2})""").find(raw) if (vbt != null) { val major = vbt.groupValues[1].toIntOrNull() ?: return null val patch = vbt.groupValues[2].toIntOrNull() ?: return null return Triple(major, 0, patch) } // MCUboot semver val mcu = Regex("""(\d+)\.(\d+)\.(\d+)""").find(raw) if (mcu != null) { return Triple( mcu.groupValues[1].toInt(), mcu.groupValues[2].toInt(), mcu.groupValues[3].toInt(), ) } return null } /** 튜플 비교: a>b 면 양수, 같으면 0, 작으면 음수. */ private fun compareSemver(a: Triple, b: Triple): Int { if (a.first != b.first) return a.first - b.first if (a.second != b.second) return a.second - b.second return a.third - b.third } /** 펌웨어 버전이 MIN_FIRMWARE_BUILD 미만이면 true. legacy "VBTFWxxxx" 만 판정. * 신규 semver 는 별도 update-available 로 처리. */ val isFirmwareOutdated: Boolean get() { val fw = firmwareVersion.value if (fw.isEmpty()) return false if (fw.startsWith("1.") || fw.contains("+")) return false val n = parseFirmwareBuild(fw) ?: return false return n < MIN_FIRMWARE_BUILD } /** * 앱 번들 DFU 가 기기 fw 보다 **엄격히 최신** 이면 true (2026-08-05). * → "새 펌웨어 사용 가능" 배너 노출. * * 매핑 규칙 (FW 팀 스펙 · VBTFW 코드표): * VBTFW MMNN = M.0.N (major=MM, minor=0, patch=NN) * MCUboot X.Y.Z+B = (X, Y, Z) · +B build 는 무시 (같은 major.minor.patch 는 동일 취급) * * 예: * device VBTFW0200 (2.0.0) · bundled 1.0.0+5 (1.0.0) → bundled < device → **false (배너 X)** * device VBTFW0100 (1.0.0) · bundled 2.0.0+0 (2.0.0) → bundled > device → **true (배너 O)** * device VBTFW0200 (2.0.0) · bundled 2.0.1+0 (2.0.1) → bundled > device → true */ val isFirmwareUpdateAvailable: Boolean get() { val fw = firmwareVersion.value if (fw.isEmpty()) return false val bundled = com.medithings.vesiscan.firmware.FirmwareLoader.BUNDLED_VERSION val devTuple = parseFwSemver(fw) ?: return false val bunTuple = parseFwSemver(bundled) ?: return false return compareSemver(bunTuple, devTuple) > 0 } val batteryLevel = mutableStateOf(0) /** 현재 연결된 디바이스 RSSI (dBm) · 미측정/미연결 시 null. dev-mode UI 오버레이용. */ val rssi = mutableStateOf(null) // ── ClinicalEventBus emit helpers (Phase D · GUARDIAN-01) ── // Battery / BLE 상태 변화 시 bus 로 broadcast. Subscriber (Notification · Guardian // publisher 등) 가 관찰. 실패해도 필수기능 영향 없음 (UC-05 · fire-and-forget). /** 마지막 배터리 bucket (0=critical<5, 1=low<20, 2=ok) · 임계 전이 감지용. -1=미측정. */ @Volatile private var lastBatteryBucket: Int = -1 private fun batteryBucketOf(pct: Int): Int = when { pct < 5 -> 0 pct < 20 -> 1 else -> 2 } private fun deviceHash(): String = com.medithings.vesiscan.telemetry.TelemetryHash.sessionId( connectedDeviceName.value.ifEmpty { lastConnectedAddress ?: "unknown" } ) /** 배터리 응답 (rsn) 파싱 후 호출. 임계 전이 시 CRITICAL/WARN, 아니면 INFO 로 emit. */ private fun emitBatteryEvent(pct: Int) { val newBucket = batteryBucketOf(pct) val prev = lastBatteryBucket val isTransition = prev != -1 && prev != newBucket // 첫 측정은 반드시 emit (guardian 이 현재 상태 파악용), 이후는 전이 시에만. val shouldEmit = prev == -1 || isTransition if (!shouldEmit) return lastBatteryBucket = newBucket val sev = when (newBucket) { 0 -> com.medithings.vesiscan.telemetry.Severity.CRITICAL 1 -> com.medithings.vesiscan.telemetry.Severity.WARN else -> com.medithings.vesiscan.telemetry.Severity.INFO } runCatching { com.medithings.vesiscan.telemetry.ClinicalEventBus.emit( com.medithings.vesiscan.telemetry.BatteryEvent( ts = System.currentTimeMillis(), sessionId = deviceHash(), severity = sev, level = pct, isTransition = isTransition, ) ) } } /** BLE 연결 상태 전이 시 emit. CONNECTED=INFO, 나머지=WARN. */ private fun emitConnectionEvent( state: com.medithings.vesiscan.telemetry.ConnState, reason: String? = null, ) { val sev = if (state == com.medithings.vesiscan.telemetry.ConnState.CONNECTED) com.medithings.vesiscan.telemetry.Severity.INFO else com.medithings.vesiscan.telemetry.Severity.WARN runCatching { com.medithings.vesiscan.telemetry.ClinicalEventBus.emit( com.medithings.vesiscan.telemetry.BleConnectionEvent( ts = System.currentTimeMillis(), sessionId = deviceHash(), severity = sev, state = state, deviceNameHash = deviceHash(), reason = reason, rssi = rssi.value, ) ) } // 연결 해제 시 배터리 bucket 리셋 → 재연결 후 첫 rsn 이 다시 초기 emit 대상. if (state != com.medithings.vesiscan.telemetry.ConnState.CONNECTED) lastBatteryBucket = -1 } val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true) val isServiceReady = mutableStateOf(false) /** 현재 GATT 연결된 BluetoothDevice · 없으면 null. Firmware DFU 진입 시 * 스캔 생략하고 바로 이 기기로 재연결하는 경로에서 사용. */ val currentBluetoothDevice: android.bluetooth.BluetoothDevice? @android.annotation.SuppressLint("MissingPermission") get() = bluetoothGatt?.device 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) /** 마지막으로 GATT status=5 (bond mismatch) 로 실패한 device 주소. * FW allowlist 1:1 bond 정책에서 다른 central 이 붙어 있는 경우 발생. * UI 가 이 값 non-null 시 "본딩 초기화" action 을 노출. 성공/타 에러로 clear. */ val bondConflictAddress = mutableStateOf(null) // Callbacks var onConnectionStateChanged: ((Boolean) -> 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") } /** * 스마트 삭제 (2026-08-04): 미연결 상태에서 삭제 시 기기 재연결 후 msr? 전송. * * 배경: 기기가 오프라인일 때 unbondAndRemoveAddress() 는 폰 측 LTK 만 삭제 → * 기기는 여전히 이전 LTK 를 기대 · 재페어링 시 PIN 불일치 · 사용자가 기기 * 15초 롱프레스로 강제 초기화해야만 재페어링 가능. * * 이 함수는: * - 이미 연결됨 → disconnectAndUnbond() (msr? + 양쪽 removeBond) → onSuccess * - 미연결 → timeoutMs 재연결 시도 * 성공 → disconnectAndUnbond() → onSuccess * 실패 → onDeviceOffline() (UI 가 강제 삭제 결정) */ fun unbondSmart( address: String, timeoutMs: Long = 5_000L, onSuccess: () -> Unit, onDeviceOffline: () -> Unit, ) { if (bluetoothGatt?.device?.address == address && isServiceReady.value) { disconnectAndUnbond() onSuccess() return } debugLogger.info("UNBOND_SMART attempting reconnect for $address (timeout=${timeoutMs}ms)") val adapter = bluetoothAdapter ?: run { onDeviceOffline(); return } val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() if (device == null) { onDeviceOffline(); return } val timeoutRunnable = Runnable { debugLogger.warn("UNBOND_SMART timeout · reconnect failed for $address") try { bluetoothGatt?.disconnect(); bluetoothGatt?.close() } catch (_: Exception) {} bluetoothGatt = null isConnecting.value = false onDeviceOffline() } val originalCallback = onConnectionStateChanged onConnectionStateChanged = { connected -> originalCallback?.invoke(connected) if (connected && bluetoothGatt?.device?.address == address) { handler.removeCallbacks(timeoutRunnable) onConnectionStateChanged = originalCallback debugLogger.info("UNBOND_SMART reconnected · sending msr? for full unbond") disconnectAndUnbond() onSuccess() } } handler.postDelayed(timeoutRunnable, timeoutMs) connectByAddress(address, 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 // 2026-08-11: 즉시 UI 반영 (홈 화면 "연결됨" stuck 방지). // gatt.disconnect() 는 비동기 · onConnectionStateChange 콜백이 몇 초 뒤 도착. // 그 사이 사용자가 goHome() 하면 isConnected.value 가 true 로 남아 UI 오동작. // Fix: 사용자 명시적 disconnect 요청이므로 UI state 즉시 false 반영. // 실제 GATT teardown 은 콜백에서 정상 진행 (idempotent). isConnected.value = false bluetoothGatt?.disconnect() com.medithings.vesiscan.services.BleForegroundService.stop(context) } fun disconnectAndUnbond() { isUserDisconnect = true cancelAutoReconnect() stopWatchdog() stopBatteryPolling() stopRssiPolling() rssi.value = null commandQueue.clear("disconnect") 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 /** * 명령 큐 (2026-08-04 · FW 팀 지시로 도입). 모든 명령은 depth=1 sequential. * 이전 응답 도착 · 또는 3초 timeout 전까지 다음 명령은 대기. * * 사용: * - `sendRaw(data)` 는 이제 단일 응답 (m→r) 명령용 wrapper (자동 enqueue). * - 스트리밍 응답 (mtb/maa/mbb/mim/mec) 은 각 send fn 에서 `commandQueue.enqueue()` * 로 직접 완료 조건 (piezoCollector.isComplete + imuCollector.samples 등) 지정. */ val commandQueue = CommandQueue( handler = handler, writeRaw = { data -> sendRawWrite(data) }, onDrop = { label, reason -> debugLogger.warn("CMDQ drop label=$label · $reason") // mtb timeout 이면 기존 UI observers 유지 (연속 timeout 카운터 등) if (label.startsWith("mtb") && reason == "timeout") { lastMtbTimeoutAt.value = System.currentTimeMillis() consecutiveMtbTimeouts.value = consecutiveMtbTimeouts.value + 1 piezoCollector.reset() imuCollector.reset() } }, ) /** 실제 GATT write — CommandQueue 내부에서만 호출. 외부는 sendRaw() 사용. */ private fun sendRawWrite(data: ByteArray) { val characteristic = txCharacteristic val gatt = bluetoothGatt if (characteristic == null) { loge { "sendRawWrite: txCharacteristic is null!" } return } if (gatt == null) { loge { "sendRawWrite: 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" "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 { "sendRawWrite: $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 { "sendRawWrite result: $result" } } else { @Suppress("DEPRECATION") characteristic.value = data characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE @Suppress("DEPRECATION") val ok = gatt.writeCharacteristic(characteristic) logd { "sendRawWrite success: $ok" } } } /** * 단일 응답 명령 (m→r 대응) 을 큐에 삽입. * * 자동 완료 조건: 3-byte 접두 (예: msn) → r + 뒤 2byte (rsn) 도착 시 완료. * 스트리밍 명령 (maa/mtb/mbb/mim/mec) 은 자체 send fn 에서 `commandQueue.enqueue()` 로 * 직접 완료 조건 지정. 이 함수로 넣으면 잘못된 tag ("rmm" 등) 를 기다리다 3초 timeout. */ fun sendRaw(data: ByteArray) { if (data.size < 4) { loge { "sendRaw: too short (${data.size}B)" } return } val cmd3 = String(data, 0, 3, Charsets.US_ASCII) val expected = "r" + cmd3.substring(1) // msn → rsn, mid → rid, mls → rls commandQueue.enqueue(QueuedCommand( data = data, label = cmd3, isDone = { it == expected }, )) } // 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) { // mec: reb×1 → raa. 종료 = raa. commandQueue.enqueue(QueuedCommand( data = CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)), label = "mec burst", isDone = { it == "raa" }, )) } fun sendAllChannels(mode: Int = 0): Boolean { if (!canSendMaa("sendAllChannels")) return false lastMaaSentMs = System.currentTimeMillis() piezoCollector.startMultiChannel(6) // maa: reb×6 → raa. 종료 = raa. commandQueue.enqueue(QueuedCommand( data = CRC16.buildCommandASCII("maa", " "), label = "maa 6ch", isDone = { it == "raa" }, )) 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() // mtb: reb×6 → raa → rim (또는 ric 분할). 종료 = piezo raa 완료 AND imu 샘플 도착. // parseRic 는 마지막 chunk 도착 시에만 samples 를 채움 → 중간 ric 은 자연스레 skip. commandQueue.enqueue(QueuedCommand( data = CRC16.buildCommandASCII("mtb", " "), label = "mtb 6ch+imu", isDone = { tag -> (tag == "rim" || tag == "ric" || tag == "raa") && piezoCollector.isComplete && imuCollector.samples.isNotEmpty() }, timeoutMs = MTB_TIMEOUT_MS, )) return true } // ── 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", " ")) } /** * FW query 자동 재시도 (2026-08-04 · GUARDIAN §Rev2 후속). * * 배경: 연결 직후 mid? (t=0) → mfv? fallback (t=4s) 만으로도 응답 없으면 * firmwareVersion 이 empty 로 남고, 8초 후 FirmwareWarningBanner 의 * `timedOut = true` 로 배너 노출. Alignment idle 처럼 트래픽이 적은 * 화면에서 특히 눈에 띔. * * 신규 로직: `scheduleFirmwareRetry()` 호출 시점부터 5초 간격으로 mid?/mfv? * 를 교차 발사. 최대 3회. 응답 오면 (firmwareVersion 채워짐) 즉시 중단. * * 실패 시: 3회 모두 응답 없음 → 재시도 종료. 배너는 그때 뜸 (진짜 문제 상황). */ fun scheduleFirmwareRetry() { fwRetryTimer?.let { handler.removeCallbacks(it) } val runnable = object : Runnable { override fun run() { // 성공 · 연결 끊김 · 최대 시도 도달 → 중단 if (firmwareVersion.value.isNotEmpty()) { debugLogger.info("FW retry: success (fw=${firmwareVersion.value})") fwRetryTimer = null return } if (!isConnected.value) { fwRetryTimer = null return } if (fwRetryCount >= FW_RETRY_MAX) { debugLogger.warn("FW retry: gave up after $FW_RETRY_MAX attempts") fwRetryTimer = null return } fwRetryCount++ // mid? / mfv? 교차 (홀수=mid, 짝수=mfv). 다른 명령이 서로 다른 FW 지원. if (fwRetryCount % 2 == 1) { debugLogger.info("FW retry #$fwRetryCount: mid?") sendDeviceInfoQuery() } else { debugLogger.info("FW retry #$fwRetryCount: mfv?") sendFirmwareVersionQuery() } handler.postDelayed(this, FW_RETRY_INTERVAL_MS) } } fwRetryTimer = runnable handler.postDelayed(runnable, FW_RETRY_INTERVAL_MS) } private fun stopFirmwareRetry() { fwRetryTimer?.let { handler.removeCallbacks(it) } fwRetryTimer = null fwRetryCount = 0 } 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() // mim: rim: (또는 ric 분할). 종료 = imu 샘플 채워짐 (마지막 rim 또는 마지막 ric). commandQueue.enqueue(QueuedCommand( data = CRC16.buildCommandASCII("mim", " "), label = "mim IMU", isDone = { tag -> (tag == "rim" || tag == "ric") && imuCollector.samples.isNotEmpty() }, )) } /** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */ fun sendFullMeasurement() { piezoCollector.startMultiChannel(6) // mbb: rbb → reb×6 → raa. 종료 = raa (piezo 완료). commandQueue.enqueue(QueuedCommand( data = CRC16.buildCommandASCII("mbb", " "), label = "mbb full", isDone = { it == "raa" && piezoCollector.isComplete }, )) } /** * 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) // maa: reb×6 → raa. 종료 = raa (piezo 완료). commandQueue.enqueue(QueuedCommand( data = CRC16.buildCommandASCII("maa", " "), label = "maa 6ch", isDone = { it == "raa" && piezoCollector.isComplete }, )) 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 } /** * RSSI polling — 2 초 주기로 `readRemoteRssi()` 요청 → `onReadRemoteRssi` 콜백이 * `rssi.value` 업데이트. dev-mode UI 오버레이가 이를 관찰한다. * 실패해도 필수기능 영향 없음 (UC-05). */ fun startRssiPolling() { stopRssiPolling() val pollRunnable = object : Runnable { override fun run() { if (!isConnected.value) return try { bluetoothGatt?.readRemoteRssi() } catch (_: Exception) {} handler.postDelayed(this, RSSI_POLL_INTERVAL_MS) } } rssiTimer = pollRunnable handler.post(pollRunnable) } fun stopRssiPolling() { rssiTimer?.let { handler.removeCallbacks(it) } rssiTimer = 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 } stopFirmwareRetry() mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } stopBatteryPolling() stopRssiPolling() rssi.value = null commandQueue.clear("watchdog-disconnect") emitConnectionEvent( com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED, reason = "watchdog zombie", ) 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() stopRssiPolling() rssi.value = null commandQueue.clear("disconnect") 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 // 2026-08-04: 사용자에게 명확한 안내. UI (DeviceScanView 등) 가 이 marker 감지 시 // "재연결 실패 · 기기 상태 확인 후 재시도" 안내 표시. connectionError.value = "RECONNECT_GAVE_UP" 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() stopRssiPolling() rssi.value = null commandQueue.clear("gatt-error") emitConnectionEvent( com.medithings.vesiscan.telemetry.ConnState.FAILED, reason = "GATT_ERROR status=$status newState=$newState", ) stopWatchdog() // 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } stopFirmwareRetry() 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 // GATT status=5 (INSUFFICIENT_AUTHENTICATION) — FW allowlist 정책상 // 다른 central 과 이미 bond 중이면 peripheral 이 인증 거부. val failedAddr = gatt.device.address if (status == 5) { // GATT_INSUFFICIENT_AUTHENTICATION — FW allowlist bond 충돌 bondConflictAddress.value = failedAddr connectionError.value = "BOND_CONFLICT:$failedAddr" debugLogger.warn("BOND_CONFLICT addr=$failedAddr — FW allowlist rejected (status 5)") } else if (status == 8 || status == 22 || status == 19) { // status=8 · GATT_CONN_TIMEOUT — link supervision timeout (peripheral 사망) // status=22 · GATT_CONN_TERMINATE_LOCAL_HOST // status=19 · GATT_CONN_TERMINATE_PEER_USER — peripheral 이 명시적 disconnect // → 세 케이스 모두 peripheral 문제 · 사용자에게 재부팅 안내 권장 bondConflictAddress.value = null connectionError.value = "DEVICE_UNRESPONSIVE:$status" debugLogger.warn("DEVICE_UNRESPONSIVE status=$status — FW hang/crash · user reboot recommended") } else { bondConflictAddress.value = null 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 bondConflictAddress.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) emitConnectionEvent(com.medithings.vesiscan.telemetry.ConnState.CONNECTED) // 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}" } emitConnectionEvent( com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED, reason = "gatt state disconnected", ) isConnecting.value = false stopBatteryPolling() stopRssiPolling() rssi.value = null commandQueue.clear("state-disconnected") // 2026-07-07 fix: disconnect 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } stopFirmwareRetry() 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() startRssiPolling() startWatchdog() logd { "Service ready (no CCCD)" } } } } } ?: run { handler.post { if (txCharacteristic != null) { isServiceReady.value = true onConnectionStateChanged?.invoke(true) startBatteryPolling() startRssiPolling() 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) // Watchdog 은 즉시 시작 (freeze 감지 필요). startWatchdog() // 2026-08-04: 첫 TX 를 INITIAL_TX_DELAY_MS 지연. // R100 등 신규 HW 는 boot 후 응답까지 여러 초 걸림 → 즉시 발사 시 // 초기 msn/mid 가 CMDQ timeout 반복 → 사용자에게 노출. fwRetryCount = 0 handler.postDelayed({ if (!isConnected.value) return@postDelayed startBatteryPolling() startRssiPolling() sendDeviceInfoQuery() // 4초 후에도 펌웨어 미수신이면 mfv? fallback. fwFallbackTimer?.let { handler.removeCallbacks(it) } val fwFallback = Runnable { if (firmwareVersion.value.isEmpty() && isConnected.value) { debugLogger.info("FW fallback: sending mfv?") sendFirmwareVersionQuery() scheduleFirmwareRetry() } } fwFallbackTimer = fwFallback handler.postDelayed(fwFallback, 4_000) }, INITIAL_TX_DELAY_MS) logd { "Service ready! (CCCD written) · initial TX in ${INITIAL_TX_DELAY_MS}ms" } } } 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) this@BleManager.rssi.value = 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) { "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) // raa 성공 → 연속 timeout 카운터 리셋 (mtb 완료 UI 관찰). // 3초 timeout 은 CommandQueue 가 소유 (기존 mtbTimeoutRunnable 은 dead code · 유지만). if (tag == "raa" && 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}%)") emitBatteryEvent(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) } // ── 공통 에러 응답 (xlsx §공통 프레임) — CommandQueue 가 pending 완료 처리 ── // rxx: Unknown command · rxd: Disabled · rxn: NULL handler · rxc: CRC fail · rxs: Too short "rxx:", "rxd:", "rxn:", "rxc:", "rxs:" -> { val tag = prefix.take(3) val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?" val reason = when (tag) { "rxx" -> "unknown_cmd" "rxd" -> "disabled_cmd" "rxn" -> "null_handler" "rxc" -> "crc_fail" "rxs" -> "too_short" else -> "err" } debugLogger.rx(tag, data.size, "$reason: $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" } } } // ── CommandQueue hook — 파서가 collector 상태 갱신한 뒤 완료 판정 ── // 3-char 태그 (예: "raa", "rim", "rxc") 로 현재 pending 명령의 isDone 실행. // True 면 pending 해제 + 큐에 대기 중이던 다음 명령 dispatch. if (data.size >= 4) { val tag3 = prefix.take(3) commandQueue.onResponseTag(tag3) } } }