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 } /** * 프로브에 저장된 측정 파라미터 (`rcs:` echo). * * [freq] 는 정상이면 0~5 지만 실패 시 오류 코드가 실려 온다 — [error] 참고. */ data class PiezoConfig( val freq: Int, val cycles: Int, val avg: Int, val delayUs: Int, val samples: Int, ) { /** 펌웨어가 알려 주는 실패 사유. 정상이면 null. */ val error: String? get() = when (freq) { 0xFFFF -> "파라미터 범위 초과 또는 데이터 길이 부족" 0xFFFD -> "NVS 저장 실패" else -> null } /** 요청한 값이 그대로 저장됐는지. 하나라도 다르면 그 설정으로 측정하면 안 된다. */ fun matches(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int): Boolean = error == null && this.freq == freq && this.cycles == cycles && this.avg == avg && this.delayUs == delayUs && this.samples == samples } @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 /** * 직전 TX 로부터 이만큼은 조용해야 배터리를 묻는다. * * ## 왜 필요한가 * `msn?` 은 **링크를 살려 두려고** 30초마다 보내던 것이다. 그런데 자동 측정(500ms 주기 * `mtb`)이나 배터리 소모 시험(1Hz `mbb`)처럼 계속 통신하는 동안에는 링크가 그 트래픽으로 * 이미 살아 있다 — 보낼 이유가 없는데 **측정 스트림 사이에 끼어든다.** * * 끼어들면 펌웨어 GATT 큐가 꼬여 응답이 실종되고 freeze 로 이어진다(2026-07-08 주석). * 종전 가드는 [isMtbBusy] 뿐이었는데 그건 **한 스트림이 흐르는 동안**만 막는다. 자동 * 측정은 cycle 사이에 완료 구간이 생기므로 그 틈에 `msn?` 이 나가고, 그 응답(`rsn`)이 * 다음 `mtb` 스트림과 겹친다. * * 5초로 잡은 이유: 자동 측정 주기(500ms)와 배터리 시험 주기(1초)보다 충분히 길어 * **측정 중에는 한 번도 나가지 않는다.** 반대로 쉬고 있으면 직전 TX 가 30초 전(지난 * 폴링)이라 정상적으로 나간다. */ private val BATTERY_TX_QUIET_MS = 5_000L /** 마지막으로 무언가 전송한 시각. 전송 관문이 갱신한다. */ @Volatile private var lastTxAtMs = 0L /** 직전 TX 로부터 [BATTERY_TX_QUIET_MS] 가 지났는가 — 즉 지금 조용한가. */ private val isTxQuiet: Boolean get() = System.currentTimeMillis() - lastTxAtMs >= BATTERY_TX_QUIET_MS // 정상 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 /** * 무한 재연결 모드 — **장시간 무인 측정 전용**. * * 평소에는 5회(약 1분)에서 포기하는 것이 맞다. 사용자가 옆에 있으니 "재연결 실패, * 기기를 확인하세요"가 무한 스캔보다 낫고, 무한 스캔은 폰 배터리를 계속 먹는다. * * 그런데 밤새 걸어 두는 부하 테스트에서는 그 정책이 그대로 실패가 된다 — 2026-09-08 * 실측에서 프로브 펌웨어가 3시간 18분 만에 얼어붙었고, 앱이 1분 뒤 포기한 뒤로 * **39분을 아무것도 안 하고 흘려보냈다.** 프로브가 리셋되어 살아나도 다시 붙지 않는다. * * 이 모드에서는 포기하지 않고 [FOREVER_SLOW_INTERVAL_MS] 간격으로 계속 시도한다. * 복구되면 부하가 자동 재개되고, 언제 죽고 언제 살아났는지가 로그와 CSV 에 남는다. * * 끄는 것을 잊으면 일반 사용에서도 무한 스캔이 도므로, 켠 쪽이 반드시 되돌린다 * (BatteryDrainTester.start/stop). */ @Volatile var reconnectForever: Boolean = false /** * 무한 모드에서 초기 5회를 넘긴 뒤의 재시도 간격. * * 3초 간격을 밤새 유지하면 스캔이 1,200회/시간이라 폰 배터리와 로그를 모두 태운다. * 프로브가 죽은 뒤 살아나는 것은 분 단위 사건이므로 30초면 충분하다. */ private val FOREVER_SLOW_INTERVAL_MS = 30_000L 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 /** * 모든 수신 프레임 tap (3-char 태그 + raw). 파싱이 끝난 뒤 CommandQueue 완료 판정 직전 호출. * 계측/진단 전용 (dev 배터리 부하 테스트) — 기능 로직은 이 콜백에 의존하지 않는다. * ⚠ BLE 콜백 스레드에서 호출됨. 무거운 작업 금지. */ var onResponseFrame: ((String, ByteArray) -> Unit)? = null val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true // Scanning // ── 스캔 빈도 제한 ──────────────────────────────────────────────────── // // 안드로이드는 앱이 **30초에 5회** 이상 스캔을 시작하면 그 뒤 스캔을 **조용히** // 막는다. 결과가 빈 채로 돌아오므로 앱에는 "광고가 없습니다" 로 보이고, 실제로는 // 기기가 멀쩡히 광고하는 중이다 (2026-09-15 실기: LED 는 광고 중이고 다른 폰에서는 // 잡히는데 이 폰만 못 봤다. 시스템 로그에 근거가 그대로 남는다 — // `E/BtGatt.GattService: App '...' is scanning too frequently`). // // 연결/해제를 빠르게 반복하면 `waitForAdvertThenConnect` 가 매번 스캔을 걸어 네다섯 // 번에 걸린다. OS 가 막기 전에 **앱에서 먼저 막고 남은 시간을 알려 준다** — 막힌 채로 // "광고가 없습니다" 를 보여 주면 사용자가 기기를 의심하게 되고, 실제로 그랬다. private val SCAN_QUOTA_WINDOW_MS = 30_000L /** OS 한도는 5 다. 하나를 남겨 둬야 자동 재연결 같은 내부 경로가 굶지 않는다. */ private val SCAN_QUOTA_MAX = 4 /** 최근 스캔 시작 시각 — [SCAN_QUOTA_WINDOW_MS] 안의 것만 들고 있는다. */ private val scanStarts = ArrayDeque() /** * 지금 스캔해도 되는가. 0 이면 가능, 아니면 **남은 대기 ms**. * 창을 벗어난 기록은 여기서 버린다. */ private fun scanQuotaWaitMs(): Long { val now = System.currentTimeMillis() while (scanStarts.isNotEmpty() && now - scanStarts.first() > SCAN_QUOTA_WINDOW_MS) { scanStarts.removeFirst() } if (scanStarts.size < SCAN_QUOTA_MAX) return 0L return SCAN_QUOTA_WINDOW_MS - (now - scanStarts.first()) } /** 스캔을 실제로 시작했을 때 부른다. */ private fun noteScanStart() { scanStarts.addLast(System.currentTimeMillis()) } /** * 제한에 걸렸으면 사용자에게 알리고 true 를 돌려준다(= 스캔하지 말 것). * `connectionError` 는 `SCAN_THROTTLED:<남은 초>` 형태다 — 화면이 초를 읽어 쓴다. */ private fun blockedByScanQuota(where: String): Boolean { val wait = scanQuotaWaitMs() if (wait <= 0L) return false val sec = (wait / 1000L) + 1L logw { "scan blocked at $where — quota, ${wait}ms left" } debugLogger.warn("SCAN_QUOTA at=$where — 스캔 빈도 제한, ${sec}초 뒤 가능") isScanning.value = false isConnecting.value = false connectionError.value = "SCAN_THROTTLED:$sec" return true } fun startScan() { if (bluetoothAdapter?.isEnabled != true) { bluetoothEnabled.value = false return } if (blockedByScanQuota("startScan")) 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) noteScanStart() 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 // **이름 검사보다 먼저** 기록한다. 광고에 이름이 안 실린 패킷도 "지금 광고 // 중"이라는 증거다 — 그걸 놓치면 멀쩡히 광고하는 기기를 기다리게 된다. lastSeenAtMs[device.address] = System.currentTimeMillis() 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)" } } } /** * 연결 타임아웃 — `connectGatt` 부터 [isServiceReady] 까지. * * 10초였는데 늘렸다. 링크가 붙은 뒤 서비스 준비까지 **15초**가 걸린 실측이 있어 * (2026-09-10 12:29 로그) 10초로 자르면 정상 연결을 실패로 끊는다. 타임아웃이 보는 * 신호를 `isConnected` → `isServiceReady` 로 바꾸면서 같이 올렸다. */ private val CONNECT_TIMEOUT_MS = 20_000L /** * 이 콜백이 **지금 쓰는 연결의 것**인가. * * `gattCallback` 은 객체 하나를 모든 연결이 공유한다. 그래서 옛 연결의 콜백이 새 연결이 * 들어선 **뒤에** 도착할 수 있다 — `gatt.disconnect()` 가 비동기라, 연결 해제 직후 * 빠르게 [연결] 을 누르면 실제로 그렇게 된다(2026-09-10 현장). * * 그때 옛 콜백이 상태를 덮으면: * · `bluetoothGatt = null` → isConnected=true 인데 sendRaw 가 하나도 안 나간다 * · `isConnected = false` → 화면은 "연결 안 됨", 실제로는 붙어 있다 * · 늦게 온 알림이 섞여 **다른 기기의 파형**이 화면에 뜬다 * * 그래서 주인이 아니면 **닫고 버린다.** 닫는 것까지 하는 이유는 그 핸들을 우리가 더 * 쓰지 않으므로 안 닫으면 누수이기 때문이다. * * `bluetoothGatt` 가 null 이면 **통과시킨다** — 연결을 막 만들어 대입 전인 구간이 있고, * 그때 막으면 STATE_CONNECTED 를 놓쳐 영원히 연결되지 않는다. */ /** * 닫은 직후 같은 기기로 다시 열기 전 대기. * * close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에 끝난다. 600ms 는 실측 * 로그에서 "2초 뒤 재시도는 성공"이던 구간을 사용자가 못 느낄 만큼으로 줄인 값이다. */ private val RECONNECT_COOLDOWN_MS = 600L /** 마지막으로 연결을 끊은 시각. 쿨다운 계산에만 쓴다. */ @Volatile private var lastDisconnectAtMs = 0L /** * 주소별로 **광고를 마지막으로 본 시각.** * * 저장된 기기를 누를 때 "지금 광고 중인가"를 판단하는 데 쓴다. 광고 전에 연결을 걸면 * 링크만 붙고 서비스가 안 올라오는 **반쪽 연결**이 된다(2026-09-10 현장 재연 ③). */ private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap() /** 이 안에 광고를 봤으면 "지금 광고 중"으로 본다. */ private val ADVERT_FRESH_MS = 5_000L /** 광고를 못 봤을 때 기다리는 시간. 넘기면 "기기를 찾을 수 없음". */ private val ADVERT_WAIT_MS = 6_000L /** 광고 확인용 스캔 콜백. 하나만 돈다. */ private var waitAdvertCallback: ScanCallback? = null private var waitAdvertTimeout: Runnable? = null /** * **명령을 실제로 보낼 수 있는가.** [isConnected] 와 다르다. * * 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은 **반쪽 연결**이 있다. 그 상태에서는 * `txCharacteristic` 이 null 이라 `sendRawWrite` 가 조용히 빠져나가고, 배터리·IMU·측정 * 어느 것도 오지 않는다. 화면은 [isConnected] 만 보고 "연결됨"이라고 했다. * * 광고 직전/직후에 [저장된 기기] 를 누르면 재연된다(2026-09-10 현장). */ val canSendCommands: Boolean get() = isServiceReady.value && txCharacteristic != null && bluetoothGatt != null /** * 쿨다운이 끝나면 연결할 예약. 두 번 예약되면 GATT 가 두 개 열린다. * * 취소도 해야 한다 — 쿨다운 중에 [연결 해제] 를 누르면, 안 끊으면 600ms 뒤에 제멋대로 * 연결된다. */ private var pendingConnect: Runnable? = null private var halfConnGuard: Runnable? = null /** * 링크가 붙은 뒤 [isServiceReady] 까지의 상한. 못 넘기면 **끊는다.** * * 반쪽 연결을 살려 두면 화면은 "연결됨"인데 아무 데이터도 오지 않고, 그 상태에서 * [페어링 삭제] 를 누르면 본드가 비대칭이 되어 기기를 물리적으로 초기화해야 한다. * 그러니 **빨리 끊고 다시 붙는 편이 낫다.** * * [connect] 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가 시작한 경로만 덮고, 이쪽은 * 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. */ /** * 이 연결에서 서비스 탐색을 이미 시작했는가 — [discoverServicesOnce] 참조. * 연결될 때마다 false 로 되돌린다. */ private var serviceDiscoveryStarted = false /** * 프로브의 MTU 협상이 지나갈 때까지 서비스 탐색을 늦추는 시간. * * 프로브는 연결 뒤 **~300ms 시점에 자기 쪽에서 MTU 협상을 건다**(폰 GATT 서버 * 로그의 `gatts_process_mtu_req: MTU 247 request from remote`). 그 ATT 트랜잭션이 * **진행 중인 서비스 탐색을 깨뜨린다** — `onServicesDiscovered` 가 영원히 오지 않고 * 반쪽 연결이 된다. 500ms 를 기다리면 협상이 끝난 뒤에 탐색이 시작되므로 깨질 * 탐색이 없다. */ private val DISCOVER_DELAY_MS = 500L /** 지연 중인 탐색 — 연결이 끊기면 취소한다. */ private var discoverPending: Runnable? = null /** * 서비스 탐색을 **이 연결에서 한 번만**, 그리고 [DISCOVER_DELAY_MS] **뒤에** 시작한다. * * ## 실측 (2026-09-15 · Xiaomi 23021RAA2Y · VBT2607R300) * 성공/실패를 가르는 것은 두 번째 `onMtuChanged` 가 **언제** 오는지 하나였다. * 오늘 9건에 예외가 없었다: * * 두 번째 콜백 +1~2ms → WATCHDOG_STARTED 3/3 * 두 번째 콜백 +297~342ms → HALF_CONNECTED 6/6 * * +1ms 는 탐색이 아직 시작도 안 했을 때라 살아남고, +300ms 는 탐색 한가운데라 * 깨진다. 그래서 **중복 호출을 막는 것으로는 고쳐지지 않았다** — 실제로 막아 봤고 * (`DISCOVER_SKIPPED`) 발동한 5회 중 3회가 여전히 반쪽이었다. 깨뜨리는 것은 우리 * 호출이 아니라 프로브의 ATT 요청이고, 그건 앱이 막을 수 없다. * * 막을 수 없으니 **피한다** — 협상이 오는 창을 지나서 탐색을 시작한다. * * 대가는 연결 완료가 0.5초 늦는 것뿐이고, 실패하면 20초 `armHalfConnectedGuard` 가 * 그대로 잡는다. 464건 전수의 "MTU 2회 → 22.6% 성공" 도 같은 현상을 다른 각도에서 * 본 것이다(docs/FIRMWARE_BLE_FINDINGS.md). * * ⚠ 근본 원인은 프로브가 연결 직후 MTU 협상을 거는 것이다. 펌웨어에서 그것을 * 없애거나 연결 직후 즉시 끝내면 이 지연은 필요 없어진다. */ private fun discoverServicesOnce(gatt: BluetoothGatt, where: String) { if (serviceDiscoveryStarted) { logw { "discoverServices skipped at $where — already started" } handler.post { debugLogger.info("DISCOVER_SKIPPED at=$where (중복 MTU 콜백)") } return } serviceDiscoveryStarted = true val r = Runnable { discoverPending = null // 지연 중에 끊겼거나 다른 연결로 바뀌었으면 그만둔다. if (gatt !== bluetoothGatt) { handler.post { debugLogger.info("DISCOVER_CANCELLED 연결이 바뀌었습니다") } return@Runnable } handler.post { debugLogger.info("DISCOVER_START at=$where (+${DISCOVER_DELAY_MS}ms)") } gatt.discoverServices() } discoverPending = r handler.postDelayed(r, DISCOVER_DELAY_MS) } /** 지연 중인 탐색을 취소한다 — 연결 정리 경로에서 부른다. */ private fun cancelPendingDiscover() { discoverPending?.let { handler.removeCallbacks(it) } discoverPending = null } private fun armHalfConnectedGuard(gatt: BluetoothGatt) { halfConnGuard?.let { handler.removeCallbacks(it) } val r = Runnable { halfConnGuard = null if (gatt !== bluetoothGatt) return@Runnable if (isServiceReady.value) return@Runnable debugLogger.error( "HALF_CONNECTED 링크는 붙었는데 서비스가 준비되지 않았습니다 " + "(${SERVICE_READY_GUARD_MS}ms) — 끊습니다" ) commandQueue.clear("half-connected") try { gatt.disconnect(); gatt.close() } catch (_: Exception) {} if (gatt === bluetoothGatt) { bluetoothGatt = null txCharacteristic = null rxCharacteristic = null } isConnected.value = false cancelPendingDiscover() isServiceReady.value = false isConnecting.value = false connectionError.value = "HALF_CONNECTED" // 2026-09-15: **여기서 재연결하지 않는다** (사용자 지시). // // 종전에는 `scheduleAutoReconnect()` 를 걸었는데, 반쪽 연결은 프로브가 갇힌 // 상태라 다시 붙어도 같은 결과가 나온다. 실측에서 23초 주기로 무한 반복했다 // (11:08~11:10 Xiaomi · RSSI −46 · 4회 연속 HALF_CONNECTED): // // 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결 → 반복 // // 링크는 매번 붙으므로 재시도 카운터가 1 로 리셋돼 escalation 도 없었다 // (`attempt=1/-1`). 화면에는 "재연결 중" 만 계속 뜨고, 조작자는 무엇을 해야 // 하는지 알 수 없다 — 임상에서 이게 제일 나쁘다. // // 이 상태를 푸는 것은 **프로브 전원 재투입**이고 앱이 할 수 있는 일이 아니다. // 그래서 멈추고 `connectionError` 로 알린다 — 사용자가 판단한다. } halfConnGuard = r handler.postDelayed(r, SERVICE_READY_GUARD_MS) } private fun clearHalfConnectedGuard() { halfConnGuard?.let { handler.removeCallbacks(it) } halfConnGuard = null } /** 링크 수립 후 서비스 준비까지 허용 시간. 실측 최악이 15초였다(2026-09-10). */ private val SERVICE_READY_GUARD_MS = 20_000L private fun isStaleGatt(gatt: BluetoothGatt, where: String): Boolean { val current = bluetoothGatt ?: return false if (gatt === current) return false logw { "stale GATT callback ignored at $where (${gatt.device.address})" } try { gatt.close() } catch (_: Exception) {} return true } // 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 var closedOld = false 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 closedOld = true } // **닫은 직후 같은 기기로 바로 connectGatt 하지 않는다.** // // close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에 끝난다. 그 틈에 // 다시 열면 0x3E(GATT_CONN_FAIL_ESTABLISH · status 62)로 떨어지거나 반쪽 연결이 // 된다 — 연결 해제 후 빠르게 [연결] 을 누를 때 나던 증상이다(2026-09-10). // // 마지막 disconnect 로부터 충분히 지났으면 기다리지 않는다 — 평소 연결이 느려지면 // 안 된다. val sinceDisconnect = System.currentTimeMillis() - lastDisconnectAtMs val cooldown = when { closedOld -> RECONNECT_COOLDOWN_MS sinceDisconnect < RECONNECT_COOLDOWN_MS -> RECONNECT_COOLDOWN_MS - sinceDisconnect else -> 0L } if (cooldown > 0L) { logd { "reconnect cooldown ${cooldown}ms" } debugLogger.info("CONNECT_COOLDOWN ${cooldown}ms (스택 정리 대기)") // 앞 예약이 남아 있으면 버린다 — 두 번 열리면 둘 중 하나가 고아가 된다. pendingConnect?.let { handler.removeCallbacks(it) } val r = Runnable { pendingConnect = null; connectNow(bleDevice) } pendingConnect = r handler.postDelayed(r, cooldown) isConnecting.value = true connectionError.value = null val i = discoveredDevices.indexOfFirst { it.address == bleDevice.address } if (i >= 0) discoveredDevices[i] = discoveredDevices[i].copy(isConnecting = true) return } pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null } connectNow(bleDevice) } /** 쿨다운이 끝난 뒤의 실제 연결. [connect] 가 전처리를 끝내고 부른다. */ private fun connectNow(bleDevice: BleDevice) { // 이전 디바이스의 state 가 남아있는 걸 방지 — 매 connect 마다 fresh. // 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확. // (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응) firmwareVersion.value = "" hardwareVersion.value = "" serialNumber.value = "" batteryLevel.value = 0 connectedDeviceName.value = "" cancelPendingDiscover() 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 } // ── 연결 타임아웃 ─────────────────────────────────────────────────── // **[isServiceReady] 를 본다. [isConnected] 가 아니다.** // // 종전에는 `!isConnected.value` 였다. 그러면 링크는 붙었는데(STATE_CONNECTED) // 서비스 탐색·CCCD 구독이 끝나지 않는 상태가 타임아웃에서 **면제**된다. 화면은 // `isServiceReady` 나 `connectionError` 중 하나를 기다리므로(DeviceScanView), // 둘 다 안 오면 스피너가 영원히 돌고 다른 기기 행까지 전부 비활성이 된다. // // 그 상태는 실제로 생긴다. 2026-09-10 12:29 로그에서 연결 직후 **15초 동안** // 모든 명령이 `CMDQ drop ... timeout` 이었다(RX 0). 그때는 풀렸지만 안 풀리면 // 무한 로딩이다 — 연결 해제 직후 재연결에서 특히 잘 난다. // // 그래서 **화면이 기다리는 것과 같은 신호**로 본다. connectionTimer?.let { handler.removeCallbacks(it) } val timeout = Runnable { if (!isServiceReady.value) { logw { "Connection timeout (isConnected=${isConnected.value} serviceReady=false)" } debugLogger.warn( "CONNECT_TIMEOUT after ${CONNECT_TIMEOUT_MS}ms " + "isConnected=${isConnected.value} serviceReady=false" ) // 링크가 붙어 있을 수 있다 — 끊고 닫아야 다음 시도가 깨끗하다. 종전에는 // 에러만 띄우고 GATT 를 살려 둬서, 다시 누르면 같은 반쪽 연결을 물고 갔다. isUserDisconnect = true cancelAutoReconnect() stopWatchdog() commandQueue.clear("connect-timeout") bluetoothGatt?.let { gatt -> try { gatt.disconnect() gatt.close() } catch (_: Exception) {} } bluetoothGatt = null txCharacteristic = null rxCharacteristic = null isConnected.value = false cancelPendingDiscover() isServiceReady.value = false isConnecting.value = false connectionError.value = "Connection timed out. Try again." if (idx >= 0 && idx < discoveredDevices.size) { discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false) } } } connectionTimer = timeout handler.postDelayed(timeout, CONNECT_TIMEOUT_MS) } /** * 특정 주소의 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, /** * 5초였는데 늘렸다. `connectByAddress` 가 광고를 최대 6초 기다린 뒤 연결하므로 * (2026-09-10), 5초면 스캔 단계에서 잘려 항상 "기기 오프라인"으로 떨어진다. */ timeoutMs: Long = 14_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 } // ── 광고를 확인하고 연결한다 ──────────────────────────────────────── // 종전에는 바로 connectGatt 했다. "스캔에 안 잡혀도 OS 본드가 있으면 된다"는 // 의도였는데, **BLE 는 상대가 광고(connectable)하지 않으면 연결이 성립하지 않는다.** // 광고 전/직후에 누르면 링크만 붙고 서비스가 안 올라오는 반쪽 연결이 되고, 그 // 상태에서 [페어링 삭제] 를 누르면 본드가 비대칭이 되어 기기를 물리적으로 // 초기화해야 한다(2026-09-10 현장 재연 ③~⑩). // // 방금 광고를 봤으면 기다리지 않는다 — 기기 목록 화면은 이미 스캔 중이라 대부분 // 이 경로다. 추가 스캔을 매번 돌리면 안드로이드 스캔 횟수 제한(30초에 5회)에 // 걸린다. val seenAgo = System.currentTimeMillis() - (lastSeenAtMs[address] ?: 0L) if (seenAgo <= ADVERT_FRESH_MS) { logd { "connectByAddress: 광고 ${seenAgo}ms 전에 봄 — 바로 연결" } connect(BleDevice(device = device, rssi = 0)) connectedDeviceName.value = name return } waitForAdvertThenConnect(address, name, device) } /** * 그 주소가 광고할 때까지 기다렸다가 연결한다. [ADVERT_WAIT_MS] 안에 못 보면 실패. * * 주소 필터를 걸어 돈다 — 전체 스캔보다 싸고, 다른 기기 광고로 깨어나지 않는다. */ private fun waitForAdvertThenConnect(address: String, name: String, device: BluetoothDevice) { val scanner = bluetoothAdapter?.bluetoothLeScanner ?: run { // 스캐너가 없으면 확인할 방법이 없다 — 종전처럼 바로 시도한다. // 반쪽 연결 가드(armHalfConnectedGuard)가 뒤를 받는다. logw { "waitForAdvert: scanner 없음 — 확인 없이 연결" } connect(BleDevice(device = device, rssi = 0)) connectedDeviceName.value = name return } cancelAdvertWait() // 광고 확인은 스캔이다 — 제한에 걸리면 여기서 멈춘다. 확인 없이 연결하면 // 광고하지 않는 기기에 붙으려다 오류가 나므로(ADVERT_WAIT 를 둔 이유) 그냥 // 기다리게 하는 것이 맞다. if (blockedByScanQuota("waitForAdvert")) return isConnecting.value = true connectionError.value = null debugLogger.info("ADVERT_WAIT $name ($address) — 광고 확인 후 연결 (최대 ${ADVERT_WAIT_MS}ms)") val cb = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (result.device.address != address) return lastSeenAtMs[address] = System.currentTimeMillis() debugLogger.info("ADVERT_FOUND $name rssi=${result.rssi} — 연결") cancelAdvertWait() connect(BleDevice(device = result.device, rssi = result.rssi)) connectedDeviceName.value = name } override fun onScanFailed(errorCode: Int) { // 스캔 자체가 안 되면(권한·횟수 제한) 확인을 포기하고 시도한다 — // 여기서 멈추면 연결할 길이 아예 없어진다. logw { "waitForAdvert: scan failed $errorCode — 확인 없이 연결" } debugLogger.warn("ADVERT_SCAN_FAIL $errorCode — 확인 없이 연결") cancelAdvertWait() connect(BleDevice(device = device, rssi = 0)) connectedDeviceName.value = name } } waitAdvertCallback = cb val filters = listOf(ScanFilter.Builder().setDeviceAddress(address).build()) val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() try { scanner.startScan(filters, settings, cb) noteScanStart() } catch (e: Exception) { logw { "waitForAdvert: startScan 실패 ${e.message} — 확인 없이 연결" } waitAdvertCallback = null connect(BleDevice(device = device, rssi = 0)) connectedDeviceName.value = name return } val to = Runnable { waitAdvertTimeout = null cancelAdvertWait() isConnecting.value = false debugLogger.warn("ADVERT_TIMEOUT $name ($address) — 광고가 없습니다") connectionError.value = "DEVICE_NOT_ADVERTISING" } waitAdvertTimeout = to handler.postDelayed(to, ADVERT_WAIT_MS) } private fun cancelAdvertWait() { waitAdvertCallback?.let { cb -> try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {} } waitAdvertCallback = null waitAdvertTimeout?.let { handler.removeCallbacks(it) } waitAdvertTimeout = null } /** 저장된 마지막 기기에 자동 연결 시도 */ 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() { lastDisconnectAtMs = System.currentTimeMillis() clearHalfConnectedGuard() cancelAdvertWait() // 쿨다운 예약을 버린다. 안 버리면 끊은 뒤 600ms 만에 스스로 다시 연결된다. pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null } 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) } /** * 양쪽 본드를 지우고 끊는다 — 프로브에 `msr?`(본드 삭제 + 재부팅), 폰에 `removeBond()`. * * ## ⚠ 보낼 수 없으면 **폰 쪽도 지우지 않는다** * 반쪽 연결에서는 `msr?` 가 한 바이트도 안 나간다([canSendCommands]). 그런데 종전에는 * 그걸 모르고 `removeBond()` 를 실행해 **본드가 비대칭**이 됐다: * * 폰: 본드 삭제 ✓ 프로브: 본드 그대로 ✗ * * 다음 연결에서 프로브가 옛 LTK 로 암호화를 요구하는데 폰엔 키가 없어 "PIN 또는 * passkey 가 올바르지 않습니다"로 거부된다. **앱으로는 복구가 안 된다** — 프로브를 * 15초 길게 눌러 본드를 초기화해야 한다(2026-09-10 현장에서 그렇게 복구했다). * * 그래서 보낼 수 없으면 **아무것도 지우지 않고** 연결만 끊고, 사용자에게 기기 초기화를 * 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시 * 연결하면 된다. */ fun disconnectAndUnbond() { if (!canSendCommands) { debugLogger.error( "UNBOND_REFUSED canSend=false serviceReady=${isServiceReady.value} " + "tx=${txCharacteristic != null} gatt=${bluetoothGatt != null} — " + "msr? 를 보낼 수 없어 폰 본드도 지우지 않습니다(비대칭 방지)" ) connectionError.value = "UNBOND_UNREACHABLE" disconnect() return } lastDisconnectAtMs = System.currentTimeMillis() pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null } 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 cancelPendingDiscover() 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 = "" cancelPendingDiscover() 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() 사용. */ /** * @return 쓰기 요청이 **실제로 스택에 들어갔는가.** false = 한 바이트도 안 나갔다. * * 반환값을 만든 이유는 [disconnectAndUnbond] 다. 반쪽 연결(링크는 붙었는데 서비스 * 미완)에서는 `txCharacteristic` 이 null 이라 여기서 조용히 빠져나갔고, 그걸 모르고 * 폰 쪽 본드만 지워 **프로브와 본드가 비대칭**이 됐다. 그 상태는 프로브를 물리적으로 * 초기화(15초 길게 누르기)해야만 복구된다 — 2026-09-10 현장 재연으로 확인. */ private fun sendRawWrite(data: ByteArray): Boolean { val characteristic = txCharacteristic val gatt = bluetoothGatt if (characteristic == null) { loge { "sendRawWrite: txCharacteristic is null!" } debugLogger.error("TX_FAIL txCharacteristic=null — 명령이 나가지 않았습니다") return false } if (gatt == null) { loge { "sendRawWrite: bluetoothGatt is null!" } debugLogger.error("TX_FAIL bluetoothGatt=null — 명령이 나가지 않았습니다") return false } 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) // 배터리 폴링이 "지금 통신 중인가"를 판단하는 근거. 전송 경로가 여럿(sendRaw 큐· // 스트리밍 명령·직접 write)이지만 전부 이 함수로 모이므로 여기 한 곳에서 남긴다. lastTxAtMs = System.currentTimeMillis() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) logd { "sendRawWrite result: $result" } // API 33+ 는 상태 코드다 — 0(SUCCESS) 만 실제로 들어간 것이다. result == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") characteristic.value = data characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE @Suppress("DEPRECATION") val ok = gatt.writeCharacteristic(characteristic) logd { "sendRawWrite success: $ok" } 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))) } /** `rcs:` echo — 프로브에 **실제로 저장된** 측정 파라미터. 아직 못 받았으면 null. */ val piezoConfigEcho = mutableStateOf(null) /** * `rcf:` 응답 — 프로브 FDS 에 저장된 현재 측정 파라미터. * * [piezoConfigEcho] 와 **따로 둔다.** 그쪽은 "내가 방금 쓴 게 먹혔나"를 확인하는 * 용도라 쓰기 직전에 null 로 비우고 기다린다. 조회 응답이 같은 자리에 들어오면 * 쓰기 검증이 엉뚱한 값을 보고 통과해 버린다. */ val piezoConfigRead = mutableStateOf(null) /** * `mcf?` — 프로브에 저장된 측정 파라미터를 **읽는다**(쓰지 않는다). * * 이게 없던 동안에는 프로브 상태를 알 방법이 아예 없었다. `mcs?` 로 써 봐야 알 수 * 있었는데 그건 이미 바꾼 것이다. 임상 모드가 조합마다 파라미터를 덮어쓰고 되돌리지 * 않으므로, 일반 측정 전에 지금 무엇이 들어 있는지 확인할 수단이 필요하다. * * 응답은 [piezoConfigRead] 로 온다. */ fun sendPiezoConfigQuery() { piezoConfigRead.value = null sendRaw(CRC16.buildCommandASCII("mcf", " ")) } /** * 측정 파라미터 (`mcs?`). 프로브 NVS 에 저장되어 전원을 껐다 켜도 유지된다. * * 2026-09-02 펌웨어팀 확인 — 이 앱이 여태 쓰던 `mpa?`(piezo power ON)는 설정 명령이 * 아니었다. 즉 지금까지 주파수·cycle 을 한 번도 바꾼 적이 없다. * * 요청 mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B * 응답 rcs: 같은 구성으로 저장된 값을 echo * * 범위: freq 0~5(0=1.8 … 5=2.3MHz) · cycles 3~7 · avg 1~10 · delay_us 0~50 · * samples 80~117. * * ⚠ **응답을 반드시 확인해야 한다.** 실패해도 `rcs:` 는 오고, 그때도 측정은 옛 * 설정으로 계속 돈다. 확인하지 않으면 파일에는 요청한 값이 적힌 채 다른 설정의 * 데이터가 쌓인다. [piezoConfigEcho] 를 보고 요청과 같은지 대조할 것. */ fun sendPiezoConfig(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int) { piezoConfigEcho.value = null sendRaw(CRC16.buildCommandBE("mcs", intArrayOf(freq, cycles, avg, delayUs, samples))) } 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 충돌 방지). // 2026-09-10: TX 중에도 건너뛴다. 연결 직후 바로 측정을 시작하는 // 흐름이 있어(배터리 시험) 이 재시도가 그 스트림에 끼어들 수 있다. if (!isMtbBusy && isTxQuiet) sendBatteryQuery() handler.postDelayed(this, BATTERY_RETRY_DELAY_MS) } } } batteryRetryTimer = retryRunnable handler.postDelayed(retryRunnable, BATTERY_RETRY_DELAY_MS) // 3단계: 30초 주기 polling — **조용할 때만 보낸다** // // 2026-09-10: `isMtbBusy` 만으로는 부족했다. 그건 한 스트림이 흐르는 동안만 막으므로, // 자동 측정처럼 cycle 이 반복되면 그 사이 완료 구간에서 `msn?` 이 나간다. 측정 중에는 // 링크가 그 트래픽으로 이미 살아 있으니 보낼 이유가 없다. val pollRunnable = object : Runnable { override fun run() { if (!isConnected.value) return when { // 건너뛴 사실을 **파일 로그**에 남긴다. logd 는 logcat 에만 가는데 // 현장에서는 파일만 받아 보므로, 그러면 "왜 배터리가 안 갱신되나"를 // 파일로 답할 수 없다. 최대 30초에 한 줄이라 부담이 없다. isMtbBusy -> debugLogger.info("BATT_POLL_SKIP mtb 응답 진행 중 — 다음 주기에") // 측정 중이다. 건너뛰고 다음 tick 에 다시 본다 — 끝나면 잡힌다. !isTxQuiet -> debugLogger.info( "BATT_POLL_SKIP 측정 중 " + "(직전 TX ${System.currentTimeMillis() - lastTxAtMs}ms 전) — 다음 주기에" ) else -> sendBatteryQuery() } handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS) } } batteryTimer = pollRunnable handler.postDelayed(pollRunnable, BATTERY_POLL_INTERVAL_MS) } /** * 배터리 mV → % 변환 + 적용. `rsn`(질의 응답)과 `rbb`(전체 측정 헤더)가 같이 쓴다. * * ## 왜 한 곳으로 모았나 * 배터리 폴링(`msn?`)을 측정 중에는 보내지 않게 바꿨다(2026-09-10). 그러면 측정하는 * 동안 표시가 멈추는데, **`mbb` 응답 헤더에는 배터리가 이미 실려 온다** — 공짜로 있는 * 값을 읽으면 추가 통신 없이 표시가 살아 있다. 배터리 소모 시험(1Hz `mbb`)이 바로 그 * 경우다. * * 변환식과 **단조 감소 가드**를 양쪽이 똑같이 써야 한다. 따로 구현하면 같은 전압이 * 경로에 따라 다른 %로 보인다. * * 단조 가드(`pct <= 현재값`)를 그대로 둔 이유: 부하가 걸리면 전압이 일시적으로 떨어졌다 * 회복하는데, 그때마다 %가 오르내리면 사용자는 배터리가 늘었다고 읽는다. */ private fun applyBatteryMv(millivolts: Int): Int { 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 } return pct } 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 cancelPendingDiscover() 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 cancelPendingDiscover() 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() { // 무한 모드에서 초기 시도를 넘기면 간격을 늘린다 — 3초 간격을 밤새 유지하면 // 스캔이 시간당 1,200회라 폰 배터리와 로그를 같이 태운다. val delay = if (reconnectForever && reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) FOREVER_SLOW_INTERVAL_MS else reconnectIntervalMs logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${delay}ms" } debugLogger.reconnecting(reconnectAttempt + 1, -1, delay) isReconnecting.value = true cancelReconnectTimer() val timer = Runnable { attemptAutoReconnect() } reconnectTimer = timer handler.postDelayed(timer, delay) } 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 시도 가드 — 무한 루프 방지. 초과 시 사용자가 명시적으로 재시도하도록. // 단 [reconnectForever] 면 포기하지 않는다(장시간 무인 측정). 간격은 // scheduleAutoReconnect 가 늦춰 두었으므로 여기서는 통과만 시킨다. if (!reconnectForever && 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 } // 제한에 걸렸으면 지금 긁지 않는다 — OS 가 막으면 빈 결과가 와서 "못 찾음" 으로 // 오해하게 된다. 재시도 경로이므로 다음 차례로 넘기면 된다. if (scanQuotaWaitMs() > 0L) { debugLogger.warn("SCAN_QUOTA at=reconnect — 스캔을 건너뛰고 다음 시도로") 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 ) // 재연결에도 **서비스 준비 타임아웃**을 건다. // // 종전에는 아무 상한이 없었다. 링크만 붙고(isConnected=true) 서비스 // 탐색·CCCD 가 끝나지 않으면: ① 아래 8초 스캔 타임아웃이 isConnected 를 // 보고 "붙었다"며 재시도를 멈추고, ② watchdog 은 isServiceReady 뒤에만 // 시작하므로 돌지 않는다. 결과는 **아무도 보지 않는 반쪽 연결**이다 — // 앱은 연결됐다고 표시하는데 명령이 전부 timeout 난다. connectionTimer?.let { handler.removeCallbacks(it) } val rcTimeout = Runnable { if (!isServiceReady.value) { debugLogger.warn( "RECONNECT_SERVICE_TIMEOUT after ${CONNECT_TIMEOUT_MS}ms " + "isConnected=${isConnected.value} — 끊고 다시 시도" ) commandQueue.clear("reconnect-service-timeout") bluetoothGatt?.let { g -> try { g.disconnect(); g.close() } catch (_: Exception) {} } bluetoothGatt = null txCharacteristic = null rxCharacteristic = null isConnected.value = false cancelPendingDiscover() isServiceReady.value = false // 사용자가 끊은 게 아니다 — 계속 되살려야 한다. scheduleAutoReconnect() } } connectionTimer = rcTimeout handler.postDelayed(rcTimeout, CONNECT_TIMEOUT_MS) } } } reconnectScanCallback = callback try { scanner.startScan(callback) noteScanStart() } 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" } if (isStaleGatt(gatt, "onConnectionStateChange status=$status newState=$newState")) return handler.post { // post 사이에 새 연결이 들어설 수 있다 — 한 번 더 본다. if (isStaleGatt(gatt, "onConnectionStateChange(post)")) return@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 cancelPendingDiscover() 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() // 위와 같은 이유로 항상 남긴다 (gatt_error 경로). if (wasConnected) { debugLogger.disconnected( if (isUserDisconnect) "user_gatt_error" else "gatt_error", status, ) } if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) { 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}" } // 새 연결이므로 서비스 탐색을 아직 시작하지 않았다 ([discoverServicesOnce]). cancelPendingDiscover() serviceDiscoveryStarted = false // 반쪽 연결 감시. 링크가 붙은 시점부터 재는 상한이다 — // connect() 의 타임아웃은 사용자가 시작한 경로에만 걸리므로, 재연결· // 자동연결로 들어온 반쪽 연결은 아무도 보지 않았다. armHalfConnectedGuard(gatt) // **이 연결을 현재 GATT 로 못박는다.** // // 재연결 경로는 `bluetoothGatt = connectGatt(...)` 로 대입하지만, // 옛 연결의 뒷북이 그 사이에 `bluetoothGatt = null` 로 덮을 수 있었다 // (연결 해제 직후 빠르게 [연결] 을 누르면 그랬다 — 2026-09-10). // 그러면 isConnected=true 인데 bluetoothGatt 가 null 이라 sendRaw 가 // 하나도 안 나간다. 여기서 다시 잡아 두면 그 상태가 남지 않는다. bluetoothGatt = gatt 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)) { // MTU 요청 실패 시 바로 진행 discoverServicesOnce(gatt, "requestMtu-failed") } // 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 cancelPendingDiscover() 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) // 2026-08-24: 끊긴 이유와 무관하게 **항상** 로그에 남긴다. // 이전에는 아래 if 안에서만 기록해서, 사용자가 직접 끊으면 // (isUserDisconnect=true) 아무 표시 없이 로그가 그냥 끊겼다. debugLogger.disconnected( if (isUserDisconnect) "user" else "unexpected", status, ) if (!isUserDisconnect && lastConnectedAddress != null) { onUnexpectedDisconnect?.invoke() scheduleAutoReconnect() } isUserDisconnect = false gatt.close() } } } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (isStaleGatt(gatt, "onMtuChanged")) return logd { "MTU changed: $mtu (status=$status)" } handler.post { debugLogger.info("MTU=$mtu") } // 프로브가 MTU 를 한 번 더 걸어오면 이 콜백이 두 번 온다 — 그때 탐색을 두 번 // 부르면 탐색이 끝나지 않고 반쪽 연결이 된다 ([discoverServicesOnce] 참조). discoverServicesOnce(gatt, "onMtuChanged") } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (isStaleGatt(gatt, "onServicesDiscovered")) return 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 clearHalfConnectedGuard() onConnectionStateChanged?.invoke(true) startBatteryPolling() startRssiPolling() startWatchdog() logd { "Service ready (no CCCD)" } } } } } ?: run { handler.post { if (txCharacteristic != null) { isServiceReady.value = true clearHalfConnectedGuard() onConnectionStateChanged?.invoke(true) startBatteryPolling() startRssiPolling() startWatchdog() logd { "Service ready (no RX)" } } } } } override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { if (isStaleGatt(gatt, "onDescriptorWrite")) return 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 (isStaleGatt(gatt, "onCharacteristicChanged")) return 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 (isStaleGatt(gatt, "onCharacteristicChanged(legacy)")) return 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 (isStaleGatt(gatt, "onReadRemoteRssi")) return 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") } "rcs:" -> { // [rcs: 4B][freq][cycles][avg][delay_us][samples] 각 BE 2B + CRC 2B = 16B. // 실패해도 이 응답은 온다 — freq 에 0xFFFF/0xFFFD 가 실린다(PiezoConfig.error). if (data.size >= 14) { fun be(i: Int) = ((data[i].toInt() and 0xFF) shl 8) or (data[i + 1].toInt() and 0xFF) val cfg = PiezoConfig(be(4), be(6), be(8), be(10), be(12)) piezoConfigEcho.value = cfg debugLogger.rx("rcs", data.size, cfg.error?.let { "설정 실패: $it" } ?: "freq=${cfg.freq} cyc=${cfg.cycles} avg=${cfg.avg} " + "delay=${cfg.delayUs} samples=${cfg.samples}") } else { debugLogger.rx("rcs", data.size, "too short") } } "rcf:" -> { // `mcf?` 응답. 배치가 `rcs:` 와 같다 — // [rcf: 4B][freq][cycles][avg][delay_us][samples] 각 BE 2B + CRC 2B = 16B. // 실패 시 freq 에 0xFFFF 가 실리는 것도 같다(PiezoConfig.error). if (data.size >= 14) { fun be(i: Int) = ((data[i].toInt() and 0xFF) shl 8) or (data[i + 1].toInt() and 0xFF) val cfg = PiezoConfig(be(4), be(6), be(8), be(10), be(12)) piezoConfigRead.value = cfg debugLogger.rx("rcf", data.size, cfg.error?.let { "조회 실패: $it" } ?: "freq=${cfg.freq} cyc=${cfg.cycles} avg=${cfg.avg} " + "delay=${cfg.delayUs} samples=${cfg.samples}") } else { debugLogger.rx("rcf", data.size, "too short") } } "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 = applyBatteryMv(millivolts) 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:" -> { // 값을 함께 남긴다 — 이 응답이 배터리 소모 실측의 유일한 10초 주기 표본인데 // 예전에는 고정 문자열만 남아 파일만 보고는 전압 추이를 전혀 알 수 없었다. // batt 는 첫 필드라 offset 4, temp 는 CRC 직전이라 size-4 (BatteryDrainTester 와 동일 규약). val detail = if (data.size >= 12) { val mv = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) val t = (((data[data.size - 4].toInt() and 0xFF) shl 8) or (data[data.size - 3].toInt() and 0xFF)) / 100f // **표시에도 반영한다.** msn? 폴링을 측정 중에는 안 보내므로, 이 값을 // 안 읽으면 배터리 소모 시험(1Hz mbb) 내내 화면이 멈춘다. 추가 통신 0. val pct = applyBatteryMv(mv) emitBatteryEvent(pct) "battery=${mv}mV (${pct}%)" + if (t in 0f..80f) " temp=%.2fC".format(t) else "" } else "full measurement header (battery+IMU+temp)" debugLogger.rx("rbb", data.size, detail) 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) try { onResponseFrame?.invoke(tag3, data) } catch (_: Exception) {} commandQueue.onResponseTag(tag3) } } }