Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt
T
dw.jang ef221a81c6 fix(ble): 배터리 폴링을 **조용할 때만** — 측정 스트림에 끼어들지 않게
`msn?` 을 30초마다 보내고 있었다. 원래 목적은 **링크를 살려 두는 것**이었는데, 자동
측정(500ms 주기 `mtb`)이나 배터리 소모 시험(1Hz `mbb`)처럼 계속 통신하는 동안에는 링크가
그 트래픽으로 이미 살아 있다 — 보낼 이유가 없는데 측정 스트림 사이에 끼어든다.

## 기존 가드로는 부족했다

`isMtbBusy` 는 **한 스트림이 흐르는 동안**만 막는다(`startMultiChannel` ~ `isComplete`).
자동 측정은 cycle 사이에 완료 구간이 생기므로 그 틈에 `msn?` 이 나가고, 그 응답(`rsn`)이
다음 `mtb` 스트림과 겹친다. 끼어들면 펌웨어 GATT 큐가 꼬여 응답이 실종되고 freeze 로
이어진다 — 2026-07-08 주석이 그 증상을 적고 있다.

## TX 시각을 보고 판단한다

전송 관문(demo-final `sendRawWrite` · user `sendRaw`)에서 `lastTxAtMs` 를 남기고, 폴링은
직전 TX 로부터 5초가 지났을 때만 보낸다. 5초는 자동 측정 주기(500ms)와 배터리 시험
주기(1초)보다 충분히 길어 **측정 중에는 한 번도 나가지 않는다.** 쉬고 있으면 직전 TX 가
30초 전(지난 폴링)이라 정상적으로 나간다.

첫 응답 재시도(3초 간격 2회)에도 같은 기준을 걸었다 — 연결 직후 바로 측정을 시작하는
흐름이 있어(배터리 시험) 그 재시도가 스트림에 끼어들 수 있다.

## 부작용을 막았다 — rbb 에서 배터리를 읽는다

폴링을 멈추면 화면의 배터리 표시가 멈춘다. 그런데 **`mbb` 응답 헤더(`rbb`)에는 배터리가
이미 실려 온다** — 앱이 그걸 안 읽고 있었다. 읽게 했더니 배터리 소모 시험(1Hz `mbb`,
50시간) 내내 표시가 살아 있고 **추가 통신은 0**이다. 원래는 배터리가 그 시험의 측정값인데
화면이 멈추는 모양새였다.

변환식과 단조 감소 가드를 `applyBatteryMv()` 한 곳으로 모았다. 따로 구현하면 같은 전압이
경로에 따라 다른 %로 보인다. 단조 가드를 그대로 둔 이유는 부하가 걸리면 전압이 일시적으로
떨어졌다 회복하는데, 그때마다 %가 오르내리면 사용자가 배터리가 늘었다고 읽기 때문이다.

## 남는 한 가지

`mtb`(자동 측정) 응답에는 배터리가 없어 **자동 측정 중에는 표시가 멈춘다.** 피할 수 없고,
원래 의도(측정 중에는 끼어들지 않는다)의 대가다. 끝나면 다음 tick 에 갱신된다.

보호자 앱에는 BLE 가 없어(아이콘 import 뿐) 대상이 아니다.

demo-final 143 · user 79 · caregiver 33 테스트 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:45:17 +09:00

2397 lines
116 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.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<BleDevice>()
val isScanning = mutableStateOf(false)
val isConnected = mutableStateOf(false)
val connectedDeviceName = mutableStateOf("")
// mid? → rid: 응답에서 파싱. 연결 직후 자동 채워짐.
val firmwareVersion = mutableStateOf("") // "VBTFW0116"
val hardwareVersion = mutableStateOf("") // "VBTHW0100"
val serialNumber = mutableStateOf("") // "VBT26040001"
/** 마지막으로 의미있는 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<Int, Int, Int>? {
// 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<Int, Int, Int>, b: Triple<Int, Int, Int>): 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<Int?>(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<String?>(null)
/** 마지막으로 GATT status=5 (bond mismatch) 로 실패한 device 주소.
* FW allowlist 1:1 bond 정책에서 다른 central 이 붙어 있는 경우 발생.
* UI 가 이 값 non-null 시 "본딩 초기화" action 을 노출. 성공/타 에러로 clear. */
val bondConflictAddress = mutableStateOf<String?>(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
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
// **이름 검사보다 먼저** 기록한다. 광고에 이름이 안 실린 패킷도 "지금 광고
// 중"이라는 증거다 — 그걸 놓치면 멀쩡히 광고하는 기기를 기다리게 된다.
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<String, Long>()
/** 이 안에 광고를 봤으면 "지금 광고 중"으로 본다. */
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 로 들어온 연결까지 덮는다.
*/
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
isServiceReady.value = false
isConnecting.value = false
connectionError.value = "HALF_CONNECTED"
// 사용자가 끊은 게 아니면 계속 되살린다.
if (!isUserDisconnect && lastConnectedAddress != null) scheduleAutoReconnect()
}
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 = ""
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
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()
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)
} 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
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() 사용. */
/**
* @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<PiezoConfig?>(null)
/**
* `rcf:` 응답 — 프로브 FDS 에 저장된 현재 측정 파라미터.
*
* [piezoConfigEcho] 와 **따로 둔다.** 그쪽은 "내가 방금 쓴 게 먹혔나"를 확인하는
* 용도라 쓰기 직전에 null 로 비우고 기다린다. 조회 응답이 같은 자리에 들어오면
* 쓰기 검증이 엉뚱한 값을 보고 통과해 버린다.
*/
val piezoConfigRead = mutableStateOf<PiezoConfig?>(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 {
isMtbBusy -> logd { "battery poll skip: mtb stream in progress" }
// 측정 중이다. 건너뛰고 다음 tick 에 다시 본다 — 끝나면 잡힌다.
!isTxQuiet -> logd {
"battery poll skip: TX active " +
"(${System.currentTimeMillis() - lastTxAtMs}ms ago)"
}
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
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() {
// 무한 모드에서 초기 시도를 넘기면 간격을 늘린다 — 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
}
// 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
isServiceReady.value = false
// 사용자가 끊은 게 아니다 — 계속 되살려야 한다.
scheduleAutoReconnect()
}
}
connectionTimer = rcTimeout
handler.postDelayed(rcTimeout, CONNECT_TIMEOUT_MS)
}
}
}
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" }
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
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}" }
// 반쪽 연결 감시. 링크가 붙은 시점부터 재는 상한이다 —
// 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)) {
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)
// 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") }
gatt.discoverServices()
}
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)
}
}
}