Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt
T
dw.jang 6dfe5e6281 fix(ble): disconnect() 시 isConnected.value 즉시 false 반영 (홈 "연결됨" stuck)
증상 (사용자 관찰 2026-08-11):
  · 설정 → Disconnect 눌러 연결 해제 후 goHome() → 홈 화면에 여전히 "● 연결됨"
    chip 표시. 실제 BLE 는 끊긴 상태.

원인:
  · BleManager.disconnect() 가 gatt.disconnect() 만 호출 · isConnected.value 는
    시스템 콜백 (onConnectionStateChange STATE_DISCONNECTED) 이 도착해야 false 로
    갱신됨. 콜백은 비동기 (수 초 지연 가능).
  · 그 사이 goHome() 실행 → HomeView 는 getter (isDemoMode || bleManager.isConnected.value)
    로 값 읽음 · 아직 true → "연결됨" 잘못 표시.
  · PiezoMonitoringView:2385 의 `appState.isDeviceConnected = false` 는 setter 가
    no-op (2026-08-03 fix 이후 getter-only) 라 무효.

수정:
  · disconnect() 첫머리에서 isConnected.value = false 즉시 세팅 (UI 층 즉시 반영).
  · 실제 GATT teardown 은 콜백에서 정상 진행 (idempotent). 재연결 시엔 그때
    콜백으로 다시 true 되므로 로직 훼손 없음.
  · disconnectAndUnbond() 는 이미 postDelayed 안에서 false 세팅 하고 있으나
    이건 사용자 요청 시엔 300ms 정도 지연이 커도 UI 오동작 없음 (즉시 goHome 없음).

빌드: BUILD SUCCESSFUL 20s. installDemoDebug 완료.
2026-08-11 16:08:25 +09:00

1813 lines
83 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
}
@SuppressLint("MissingPermission")
class BleManager private constructor(private val context: Context) {
companion object {
private const val TAG = "BleManager"
/** 정상 측정에 필요한 최소 펌웨어 빌드 번호 (mtb? throttle/MTU 가정과 일치). */
const val MIN_FIRMWARE_BUILD = 118
private inline fun logd(msg: () -> String) { if (BuildConfig.DEBUG) Log.d(TAG, msg()) }
private inline fun logw(msg: () -> String) { if (BuildConfig.DEBUG) Log.w(TAG, msg()) }
private inline fun loge(msg: () -> String) { if (BuildConfig.DEBUG) Log.e(TAG, msg()) }
@Volatile
private var instance: BleManager? = null
fun getInstance(context: Context): BleManager =
instance ?: synchronized(this) {
instance ?: BleManager(context.applicationContext).also { instance = it }
}
/** Logger 같은 context-free 컴포넌트에서 BLE state 읽기용. instance 생성 후에만 non-null. */
val current: BleManager? get() = instance
val SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
val TX_CHAR_UUID: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
val RX_CHAR_UUID: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private var bluetoothLeScanner: BluetoothLeScanner? = null
@Volatile private var bluetoothGatt: BluetoothGatt? = null
@Volatile private var txCharacteristic: BluetoothGattCharacteristic? = null
@Volatile private var rxCharacteristic: BluetoothGattCharacteristic? = null
private val handler = Handler(Looper.getMainLooper())
val debugLogger = com.medithings.vesiscan.services.BleDebugLogger.getInstance(context)
private var scanTimer: Runnable? = null
private var batteryTimer: Runnable? = null
private var batteryRetryTimer: Runnable? = null
private val BATTERY_POLL_INTERVAL_MS = 30_000L // 정상 polling 간격
private val BATTERY_RETRY_DELAY_MS = 3_000L // 첫 응답 재시도 지연
private val BATTERY_RETRY_MAX = 2 // 최대 재시도 횟수
private var rssiTimer: Runnable? = null
private val RSSI_POLL_INTERVAL_MS = 2_000L // dev-mode UI polling 간격
private var connectionTimer: Runnable? = null
private var isUserDisconnect = false
var lastConnectedAddress: String? = null
private set
private var reconnectAttempt = 0
private val reconnectIntervalMs: Long = 3000
/** max retry — 10회 시도 후 포기 (≈ 8s scan × 10 + 간격, 약 100~120초). */
// 2026-08-04: 10 → 5 축소. 지속 실패 시 사용자에게 명확한 안내가 낫고 무한 시도로
// 상단 배너/로그 스팸 방지. 실패 후 사용자가 명시적으로 재시도 (앱 재시작 · 재연결).
private val MAX_RECONNECT_ATTEMPTS = 5
private var reconnectTimer: Runnable? = null
@Volatile private var lastRxTimestamp: Long = 0
private var watchdogTimer: Runnable? = null
// 2026-07-07: 15 → 25초. 실측 로그 (2026-07-07 10:43~10:44) 에서 재연결 후 첫 RX 가
// 14초 지연 후 도착하는 케이스 확인. Peripheral / OS BLE 스택의 좀비 회복 시간을
// 허용하기 위해 timeout 여유 확보. 15초로는 회복 직전에 forced reconnect 발생 →
// 무한 재연결 루프. 25초면 대부분 회복 케이스 커버 가능.
private val watchdogTimeoutMs: Long = 25000
// 2026-07-07 fix: disconnect 시 취소 위해 Runnable 참조 유지.
// fwFallback: onDescriptorWrite 성공 후 4초 mfv? fallback.
// fwRetry: 2026-08-04 신규 · mid?/mfv? 초기 응답 실패 시 5초 간격 재시도 최대 3회.
// cccdRetry: onDescriptorWrite 실패 후 500ms retry.
private var fwFallbackTimer: Runnable? = null
private var fwRetryTimer: Runnable? = null
@Volatile private var fwRetryCount: Int = 0
private val FW_RETRY_MAX = 3
private val FW_RETRY_INTERVAL_MS = 5_000L
/**
* CONNECTED (CCCD write 성공) 후 첫 명령까지 대기 시간.
* 2026-08-04: R100 목업 유닛이 boot 후 응답까지 9초 걸리는 케이스 관찰 (VBT0607R100 로그).
* 첫 msn/mid 를 즉시 발사 → 3초 CMDQ timeout 3회 반복 → 사용자에게 노출.
* 2초 지연 두면 FW startup 이 조금 여유 있을 때 첫 명령 통과 확률 상승.
* 여전히 매우 느린 FW 는 재시도 로직 (scheduleFirmwareRetry) 이 커버.
*/
private val INITIAL_TX_DELAY_MS = 2_000L
private var cccdRetryTimer: Runnable? = null
val discoveredDevices = mutableStateListOf<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 onNirsPowerOnReceived: (() -> Unit)? = null
var onNirsSensorActivated: (() -> Unit)? = null
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
var onUnexpectedDisconnect: (() -> Unit)? = null
var onReconnectionFailed: (() -> Unit)? = null
var onImuReceived: ((ByteArray) -> Unit)? = null
var onMbbHeaderReceived: ((ByteArray) -> Unit)? = null
val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true
// Scanning
fun startScan() {
if (bluetoothAdapter?.isEnabled != true) {
bluetoothEnabled.value = false
return
}
bluetoothEnabled.value = true
discoveredDevices.clear()
isScanning.value = true
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
if (bluetoothLeScanner == null) {
loge { "BluetoothLeScanner is null" }
isScanning.value = false
return
}
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
try {
bluetoothLeScanner?.startScan(null, settings, scanCallback)
logd { "BLE scan started" }
} catch (e: Exception) {
loge { "Failed to start scan: ${e.message}" }
isScanning.value = false
return
}
scanTimer?.let { handler.removeCallbacks(it) }
val timeout = Runnable { stopScan() }
scanTimer = timeout
handler.postDelayed(timeout, 15000)
}
fun stopScan() {
try {
bluetoothLeScanner?.stopScan(scanCallback)
} catch (e: Exception) {
logw { "Failed to stop scan: ${e.message}" }
}
isScanning.value = false
scanTimer?.let { handler.removeCallbacks(it) }
scanTimer = null
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val name = try { device.name } catch (_: Exception) { null } ?: return
if (name.isEmpty()) return
handler.post {
val existing = discoveredDevices.indexOfFirst { it.address == device.address }
if (existing >= 0) {
discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi)
} else {
discoveredDevices.add(BleDevice(device, result.rssi))
logd { "Discovered: $name (${device.address}) rssi=${result.rssi}" }
}
}
}
override fun onScanFailed(errorCode: Int) {
loge { "Scan failed with error code: $errorCode" }
handler.post {
isScanning.value = false
connectionError.value = "Scan failed (error $errorCode)"
}
}
}
// Connection
fun connect(bleDevice: BleDevice) {
stopScan()
cancelAutoReconnect()
reconnectScanCallback?.let { cb ->
try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
reconnectScanCallback = null
}
reconnectConnecting = false
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
// Clean up any existing GATT connection
bluetoothGatt?.let { gatt ->
logd { "Closing existing GATT connection" }
try {
gatt.disconnect()
gatt.close()
} catch (e: Exception) {
logw { "Error closing old GATT: ${e.message}" }
}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
}
// 이전 디바이스의 state 가 남아있는 걸 방지 — 매 connect 마다 fresh.
// 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확.
// (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응)
firmwareVersion.value = ""
hardwareVersion.value = ""
serialNumber.value = ""
batteryLevel.value = 0
connectedDeviceName.value = ""
isServiceReady.value = false
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
connectionError.value = null
isConnecting.value = true
try {
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
} catch (e: Exception) {
loge { "connectGatt failed: ${e.message}" }
isConnecting.value = false
handler.post {
connectionError.value = "Connection failed: ${e.message}"
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
}
return
}
// Connection timeout
connectionTimer?.let { handler.removeCallbacks(it) }
val timeout = Runnable {
if (!isConnected.value) {
logw { "Connection timeout" }
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
gatt.close()
} catch (_: Exception) {}
}
bluetoothGatt = null
connectionError.value = "Connection timed out. Try again."
if (idx >= 0 && idx < discoveredDevices.size) {
discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
}
}
}
connectionTimer = timeout
handler.postDelayed(timeout, 10000)
}
/**
* 특정 주소의 known device 페어링 해제 — 현재 연결된 기기가 아니어도 사용 가능.
* 연결된 상태라면 일반 disconnectAndUnbond() 를 사용.
*/
fun unbondAndRemoveAddress(address: String) {
// 현재 연결된 기기를 해제하려는 경우
if (bluetoothGatt?.device?.address == address) {
disconnectAndUnbond()
return
}
// 연결 안 된 known device — OS bond 만 끊고 list 에서 제거
val adapter = bluetoothAdapter ?: return
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull()
device?.let { dev ->
try {
val method = dev.javaClass.getMethod("removeBond")
method.invoke(dev)
logd { "removeBond called for ${dev.address}" }
} catch (e: Exception) {
logw { "removeBond failed: ${e.message}" }
}
}
com.medithings.vesiscan.services.KnownDeviceStore.remove(context, address)
debugLogger.info("DEVICE_UNREGISTERED $address")
}
/**
* 스마트 삭제 (2026-08-04): 미연결 상태에서 삭제 시 기기 재연결 후 msr? 전송.
*
* 배경: 기기가 오프라인일 때 unbondAndRemoveAddress() 는 폰 측 LTK 만 삭제 →
* 기기는 여전히 이전 LTK 를 기대 · 재페어링 시 PIN 불일치 · 사용자가 기기
* 15초 롱프레스로 강제 초기화해야만 재페어링 가능.
*
* 이 함수는:
* - 이미 연결됨 → disconnectAndUnbond() (msr? + 양쪽 removeBond) → onSuccess
* - 미연결 → timeoutMs 재연결 시도
* 성공 → disconnectAndUnbond() → onSuccess
* 실패 → onDeviceOffline() (UI 가 강제 삭제 결정)
*/
fun unbondSmart(
address: String,
timeoutMs: Long = 5_000L,
onSuccess: () -> Unit,
onDeviceOffline: () -> Unit,
) {
if (bluetoothGatt?.device?.address == address && isServiceReady.value) {
disconnectAndUnbond()
onSuccess()
return
}
debugLogger.info("UNBOND_SMART attempting reconnect for $address (timeout=${timeoutMs}ms)")
val adapter = bluetoothAdapter ?: run { onDeviceOffline(); return }
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull()
if (device == null) { onDeviceOffline(); return }
val timeoutRunnable = Runnable {
debugLogger.warn("UNBOND_SMART timeout · reconnect failed for $address")
try { bluetoothGatt?.disconnect(); bluetoothGatt?.close() } catch (_: Exception) {}
bluetoothGatt = null
isConnecting.value = false
onDeviceOffline()
}
val originalCallback = onConnectionStateChanged
onConnectionStateChanged = { connected ->
originalCallback?.invoke(connected)
if (connected && bluetoothGatt?.device?.address == address) {
handler.removeCallbacks(timeoutRunnable)
onConnectionStateChanged = originalCallback
debugLogger.info("UNBOND_SMART reconnected · sending msr? for full unbond")
disconnectAndUnbond()
onSuccess()
}
}
handler.postDelayed(timeoutRunnable, timeoutMs)
connectByAddress(address, address)
}
/**
* 저장된 기기 주소로 직접 연결. 스캔 결과에 안 잡혀도 OS bond 가 있으면 바로 됨.
* KnownDeviceStore 에서 골라 호출.
*/
fun connectByAddress(address: String, name: String) {
val adapter = bluetoothAdapter ?: run {
connectionError.value = "Bluetooth not available"
return
}
val device = try {
adapter.getRemoteDevice(address)
} catch (e: IllegalArgumentException) {
connectionError.value = "Invalid device address"
return
}
connect(BleDevice(device = device, rssi = 0))
// discoveredDevices 에 없을 수 있으므로 name 을 별도로 저장 (기존 connect 가 onConnected 시점에 name 설정)
connectedDeviceName.value = name
}
/** 저장된 마지막 기기에 자동 연결 시도 */
fun autoConnectLastDevice() {
if (isConnected.value) return
val recent = com.medithings.vesiscan.services.KnownDeviceStore.mostRecent(context) ?: return
debugLogger.info("AUTO_CONNECT attempting ${recent.name} (${recent.address})")
lastConnectedAddress = recent.address
scheduleAutoReconnect()
}
fun disconnect() {
isUserDisconnect = true
cancelAutoReconnect()
stopWatchdog()
connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null
// 2026-08-11: 즉시 UI 반영 (홈 화면 "연결됨" stuck 방지).
// gatt.disconnect() 는 비동기 · onConnectionStateChange 콜백이 몇 초 뒤 도착.
// 그 사이 사용자가 goHome() 하면 isConnected.value 가 true 로 남아 UI 오동작.
// Fix: 사용자 명시적 disconnect 요청이므로 UI state 즉시 false 반영.
// 실제 GATT teardown 은 콜백에서 정상 진행 (idempotent).
isConnected.value = false
bluetoothGatt?.disconnect()
com.medithings.vesiscan.services.BleForegroundService.stop(context)
}
fun disconnectAndUnbond() {
isUserDisconnect = true
cancelAutoReconnect()
stopWatchdog()
stopBatteryPolling()
stopRssiPolling()
rssi.value = null
commandQueue.clear("disconnect")
com.medithings.vesiscan.services.BleForegroundService.stop(context)
connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null
// 기기에 본딩 삭제 + 재부팅 명령 (msr?)
sendRaw(CRC16.buildCommandASCII("msr", " "))
debugLogger.info("TX msr? unbond & reboot device")
val device = bluetoothGatt?.device
// 기기가 재부팅되므로 약간 대기 후 GATT 정리
handler.postDelayed({
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
batteryLevel.value = 0
lastConnectedAddress = null
// Android 측 본딩 정보도 삭제
device?.let { dev ->
try {
val method = dev.javaClass.getMethod("removeBond")
method.invoke(dev)
logd { "removeBond called for ${dev.address}" }
} catch (e: Exception) {
logw { "removeBond failed: ${e.message}" }
}
}
// 저장된 기기 정보 삭제 — known device list 에서도 제거
try {
device?.let { dev ->
com.medithings.vesiscan.services.KnownDeviceStore.remove(context, dev.address)
}
} catch (_: Exception) {}
debugLogger.info("DEVICE_UNREGISTERED")
}, 300)
}
// Reconnection
fun reconnectToLastDevice(lastDeviceAddress: String?) {
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
onReconnectionFailed?.invoke()
return
}
// Stop any active reconnect scan first
reconnectScanCallback?.let { cb ->
try { bluetoothAdapter.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
reconnectScanCallback = null
}
reconnectConnecting = false
// Clean up existing
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
bluetoothGatt = null
}
// 이전 디바이스 state fresh (다른 디바이스로 스위치하는 reconnect 케이스 대비)
firmwareVersion.value = ""
hardwareVersion.value = ""
serialNumber.value = ""
batteryLevel.value = 0
connectedDeviceName.value = ""
isServiceReady.value = false
isReconnecting.value = true
val device = bluetoothAdapter.getRemoteDevice(lastDeviceAddress)
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
handler.postDelayed({
if (isReconnecting.value) {
isReconnecting.value = false
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
}
bluetoothGatt = null
onReconnectionFailed?.invoke()
}
}, 10000)
}
// Send
/**
* 명령 큐 (2026-08-04 · FW 팀 지시로 도입). 모든 명령은 depth=1 sequential.
* 이전 응답 도착 · 또는 3초 timeout 전까지 다음 명령은 대기.
*
* 사용:
* - `sendRaw(data)` 는 이제 단일 응답 (m→r) 명령용 wrapper (자동 enqueue).
* - 스트리밍 응답 (mtb/maa/mbb/mim/mec) 은 각 send fn 에서 `commandQueue.enqueue()`
* 로 직접 완료 조건 (piezoCollector.isComplete + imuCollector.samples 등) 지정.
*/
val commandQueue = CommandQueue(
handler = handler,
writeRaw = { data -> sendRawWrite(data) },
onDrop = { label, reason ->
debugLogger.warn("CMDQ drop label=$label · $reason")
// mtb timeout 이면 기존 UI observers 유지 (연속 timeout 카운터 등)
if (label.startsWith("mtb") && reason == "timeout") {
lastMtbTimeoutAt.value = System.currentTimeMillis()
consecutiveMtbTimeouts.value = consecutiveMtbTimeouts.value + 1
piezoCollector.reset()
imuCollector.reset()
}
},
)
/** 실제 GATT write — CommandQueue 내부에서만 호출. 외부는 sendRaw() 사용. */
private fun sendRawWrite(data: ByteArray) {
val characteristic = txCharacteristic
val gatt = bluetoothGatt
if (characteristic == null) {
loge { "sendRawWrite: txCharacteristic is null!" }
return
}
if (gatt == null) {
loge { "sendRawWrite: bluetoothGatt is null!" }
return
}
val cmdPreview = if (data.size >= 4) String(data, 0, 3, Charsets.US_ASCII) else "?"
val cmdDetail = when (cmdPreview) {
"maa" -> "6ch measure"
"mpa" -> "piezo power ON"
"mps" -> "piezo stop"
"mec" -> "single burst"
"msn" -> "battery query"
"mta" -> if (data.size > 4 && data[4].toInt() == 1) "NIRS power ON" else "NIRS power OFF"
"mqq" -> "NIRS activate"
"mag" -> "NIRS gain query"
"mcj" -> "NIRS MCJ query"
"mid" -> "device info query"
// mls 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low
"mls" -> "LED mode ${
if (data.size > 5) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
else "?"
}"
"msp" -> "IMU query"
"mim" -> "IMU FIFO (15 samples)"
"mbb" -> "full measurement (battery+IMU+temp+6ch)"
else -> ""
}
logd { "sendRawWrite: $cmdPreview (${data.size} bytes)" }
debugLogger.tx("$cmdPreview $cmdDetail", data.size)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
logd { "sendRawWrite result: $result" }
} else {
@Suppress("DEPRECATION")
characteristic.value = data
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@Suppress("DEPRECATION")
val ok = gatt.writeCharacteristic(characteristic)
logd { "sendRawWrite success: $ok" }
}
}
/**
* 단일 응답 명령 (m→r 대응) 을 큐에 삽입.
*
* 자동 완료 조건: 3-byte 접두 (예: msn) → r + 뒤 2byte (rsn) 도착 시 완료.
* 스트리밍 명령 (maa/mtb/mbb/mim/mec) 은 자체 send fn 에서 `commandQueue.enqueue()` 로
* 직접 완료 조건 지정. 이 함수로 넣으면 잘못된 tag ("rmm" 등) 를 기다리다 3초 timeout.
*/
fun sendRaw(data: ByteArray) {
if (data.size < 4) {
loge { "sendRaw: too short (${data.size}B)" }
return
}
val cmd3 = String(data, 0, 3, Charsets.US_ASCII)
val expected = "r" + cmd3.substring(1) // msn → rsn, mid → rid, mls → rls
commandQueue.enqueue(QueuedCommand(
data = data,
label = cmd3,
isDone = { it == expected },
))
}
// Piezo Commands
val piezoCollector = PiezoPacketCollector()
// IMU rim: 응답 파서. mtb? 명령 시 채워짐. PlacementGuide/Clinical에서 콜백 등록.
val imuCollector = ImuPacketCollector()
// ── Piezo Commands (테스터 exe와 동일 포맷: 파라미터=BE, 빈 파라미터=공백) ──
fun sendPiezoPowerOn(freqOption: Int = 2, cycles: Int = 5) {
sendRaw(CRC16.buildCommandBE("mpa", intArrayOf(freqOption, cycles)))
}
fun sendPiezoStop() {
sendRaw(CRC16.buildCommandASCII("mps", " "))
}
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
// mec: reb×1 → raa. 종료 = raa.
commandQueue.enqueue(QueuedCommand(
data = CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)),
label = "mec burst",
isDone = { it == "raa" },
))
}
fun sendAllChannels(mode: Int = 0): Boolean {
if (!canSendMaa("sendAllChannels")) return false
lastMaaSentMs = System.currentTimeMillis()
piezoCollector.startMultiChannel(6)
// maa: reb×6 → raa. 종료 = raa.
commandQueue.enqueue(QueuedCommand(
data = CRC16.buildCommandASCII("maa", " "),
label = "maa 6ch",
isDone = { it == "raa" },
))
return true
}
/**
* 정렬 모드 측정 (mtb?) — Piezo 6채널 + IMU 동시 캡처.
* 응답: reb: × 6 + raa: + rim: (각각 piezoCollector / imuCollector가 처리)
*
* canSendMaa() 게이트는 maa와 공유 (lastMaaSentMs 같이 사용).
*/
fun sendMtb(): Boolean {
if (!canSendMaa("sendMtb")) return false
lastMaaSentMs = System.currentTimeMillis()
piezoCollector.startMultiChannel(6)
imuCollector.reset()
// mtb: reb×6 → raa → rim (또는 ric 분할). 종료 = piezo raa 완료 AND imu 샘플 도착.
// parseRic 는 마지막 chunk 도착 시에만 samples 를 채움 → 중간 ric 은 자연스레 skip.
commandQueue.enqueue(QueuedCommand(
data = CRC16.buildCommandASCII("mtb", " "),
label = "mtb 6ch+imu",
isDone = { tag ->
(tag == "rim" || tag == "ric" || tag == "raa") &&
piezoCollector.isComplete &&
imuCollector.samples.isNotEmpty()
},
timeoutMs = MTB_TIMEOUT_MS,
))
return true
}
// ── NIRS Commands ──
fun sendNirsPowerOn() {
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(1)))
}
fun sendNirsPowerOff() {
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(0)))
}
fun sendMqqQuery() {
sendRaw(CRC16.buildCommandASCII("mqq", " "))
}
fun sendMagQuery() {
sendRaw(CRC16.buildCommandASCII("mag", " "))
}
fun sendMcjQuery() {
sendRaw(CRC16.buildCommandASCII("mcj", " "))
}
// ── Common Commands ──
fun sendBatteryQuery() {
// mid?/mtb?/maa? 등 다른 단순 query와 동일한 ASCII " " 형식 — BE [0]은
// 펌웨어가 응답 안 함이 확인됨 (rsn: 자체가 안 옴, 2026-06-08).
sendRaw(CRC16.buildCommandASCII("msn", " "))
}
fun sendDeviceInfoQuery() {
sendRaw(CRC16.buildCommandASCII("mid", " "))
}
/** mfv? — FW Version only. mid? 응답 안 오는 펌웨어용 fallback. */
fun sendFirmwareVersionQuery() {
sendRaw(CRC16.buildCommandASCII("mfv", " "))
}
/**
* FW query 자동 재시도 (2026-08-04 · GUARDIAN §Rev2 후속).
*
* 배경: 연결 직후 mid? (t=0) → mfv? fallback (t=4s) 만으로도 응답 없으면
* firmwareVersion 이 empty 로 남고, 8초 후 FirmwareWarningBanner 의
* `timedOut = true` 로 배너 노출. Alignment idle 처럼 트래픽이 적은
* 화면에서 특히 눈에 띔.
*
* 신규 로직: `scheduleFirmwareRetry()` 호출 시점부터 5초 간격으로 mid?/mfv?
* 를 교차 발사. 최대 3회. 응답 오면 (firmwareVersion 채워짐) 즉시 중단.
*
* 실패 시: 3회 모두 응답 없음 → 재시도 종료. 배너는 그때 뜸 (진짜 문제 상황).
*/
fun scheduleFirmwareRetry() {
fwRetryTimer?.let { handler.removeCallbacks(it) }
val runnable = object : Runnable {
override fun run() {
// 성공 · 연결 끊김 · 최대 시도 도달 → 중단
if (firmwareVersion.value.isNotEmpty()) {
debugLogger.info("FW retry: success (fw=${firmwareVersion.value})")
fwRetryTimer = null
return
}
if (!isConnected.value) {
fwRetryTimer = null
return
}
if (fwRetryCount >= FW_RETRY_MAX) {
debugLogger.warn("FW retry: gave up after $FW_RETRY_MAX attempts")
fwRetryTimer = null
return
}
fwRetryCount++
// mid? / mfv? 교차 (홀수=mid, 짝수=mfv). 다른 명령이 서로 다른 FW 지원.
if (fwRetryCount % 2 == 1) {
debugLogger.info("FW retry #$fwRetryCount: mid?")
sendDeviceInfoQuery()
} else {
debugLogger.info("FW retry #$fwRetryCount: mfv?")
sendFirmwareVersionQuery()
}
handler.postDelayed(this, FW_RETRY_INTERVAL_MS)
}
}
fwRetryTimer = runnable
handler.postDelayed(runnable, FW_RETRY_INTERVAL_MS)
}
private fun stopFirmwareRetry() {
fwRetryTimer?.let { handler.removeCallbacks(it) }
fwRetryTimer = null
fwRetryCount = 0
}
fun sendLedMode(state: Int) {
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
}
// 2026-07-08: sendImuQuery() (msp) 완전 제거. 신 firmware 는 mim (FIFO 15 sample) 만 사용.
// Watchdog heartbeat 도 sendImuFifoQuery (mim) 로 통일.
// ※ debugLogger 명령 매핑 (msp → "IMU query") 은 legacy 응답 (rsp:) 파싱용으로 유지.
/**
* mtb 응답 진행 중 여부 — Battery/IMU 폴링이 mtb 응답 stream (reb×6 + raa + rim) 중간에
* 끼어들면 firmware GATT queue 가 꼬여 응답 실종 → freeze 유발.
* 이 property 를 batteryTimer / mim polling / watchdog heartbeat 앞에서 확인해 skip.
*/
val isMtbBusy: Boolean
get() = piezoCollector.isMultiChannel && !piezoCollector.isComplete
/**
* mtb 응답 3초 timeout 후 갱신되는 마지막 timeout 시각. 상위 UI 가 관찰해서
* "기기 응답 지연" 배너 표시. 0 = 아직 timeout 없음.
*/
val lastMtbTimeoutAt = mutableStateOf(0L)
/** mtb 연속 timeout 카운터. 3회 이상 시 재연결 유도. */
val consecutiveMtbTimeouts = mutableStateOf(0)
private var mtbTimeoutRunnable: Runnable? = null
private val MTB_TIMEOUT_MS = 3000L
/**
* IMU FIFO 폴링 (2026-07-03 펌웨어 신규 mim?):
* - piezo burst 없음 (무음)
* - IMU 15 sample (300ms window) 를 rim: 프레임으로 반환
* - 응답 스키마는 mtb? 의 rim: 와 동일 → imuCollector.parseRim + onComplete 재사용
* idle 폴링에서 walking detector 의 sliding window 를 채우기 위해 사용.
* mtb 진행 중에는 imuCollector.reset() 이 mtb 의 rim 을 파괴하므로 caller 가 gating 필수.
*/
fun sendImuFifoQuery() {
imuCollector.reset()
// mim: rim: (또는 ric 분할). 종료 = imu 샘플 채워짐 (마지막 rim 또는 마지막 ric).
commandQueue.enqueue(QueuedCommand(
data = CRC16.buildCommandASCII("mim", " "),
label = "mim IMU",
isDone = { tag ->
(tag == "rim" || tag == "ric") && imuCollector.samples.isNotEmpty()
},
))
}
/** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */
fun sendFullMeasurement() {
piezoCollector.startMultiChannel(6)
// mbb: rbb → reb×6 → raa. 종료 = raa (piezo 완료).
commandQueue.enqueue(QueuedCommand(
data = CRC16.buildCommandASCII("mbb", " "),
label = "mbb full",
isDone = { it == "raa" && piezoCollector.isComplete },
))
}
/**
* 6채널 측정 명령(maa) 송신 가능 여부:
* (A) 이전 응답이 아직 진행 중(isMultiChannel && !isComplete)이면 차단.
* 단 3초 넘게 응답이 안 오면 강제 통과(영영 deadlock 방지).
* (B) 마지막 송신으로부터 500ms 미만이면 차단(펌웨어 burst 방어).
*
* FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 조합에서 한 사이클 응답이
* 약 280~420ms이므로, 500ms = 응답 평균 + 안전 마진 ~80~220ms.
*/
private var lastMaaSentMs = 0L
private fun canSendMaa(caller: String): Boolean {
val now = System.currentTimeMillis()
val collectorBusy = piezoCollector.isMultiChannel && !piezoCollector.isComplete
val sinceLast = now - lastMaaSentMs
if (collectorBusy) {
if (sinceLast < 3000) {
android.util.Log.w("BleManager", "maa BUSY [$caller] — prev response in progress (${sinceLast}ms)")
return false
}
android.util.Log.w("BleManager", "maa FORCE [$caller] — prev incomplete after ${sinceLast}ms, abandoning")
}
if (sinceLast < 500) {
android.util.Log.w("BleManager", "maa THROTTLED [$caller] — ${sinceLast}ms since last")
return false
}
return true
}
fun sendChannelsOnly(): Boolean {
if (!canSendMaa("sendChannelsOnly")) return false
lastMaaSentMs = System.currentTimeMillis()
piezoCollector.startMultiChannel(6)
// maa: reb×6 → raa. 종료 = raa (piezo 완료).
commandQueue.enqueue(QueuedCommand(
data = CRC16.buildCommandASCII("maa", " "),
label = "maa 6ch",
isDone = { it == "raa" && piezoCollector.isComplete },
))
return true
}
/**
* 배터리 polling 시작.
*
* 이전엔 "1회 송신만" 이었고, 그 한 번이 큐 경합/타이밍 문제로 손실되면 배터리가
* 영원히 0%로 표시되는 이슈가 있었음. 이제 세 단계로 보장:
* 1. 즉시 1회 송신 (best case)
* 2. 3초 내 응답 없으면(batteryLevel.value == 0) 최대 2회 재송신 (첫 응답 누락 보강)
* 3. 30초 주기 정상 polling (충전 반영 + 장기 추적)
*
* BLE 끊김 / 측정 시작 시 stopBatteryPolling()으로 정리.
*/
fun startBatteryPolling() {
stopBatteryPolling() // 기존 타이머 안전 정리
sendBatteryQuery() // 1단계: 즉시 1회
// 2단계: 첫 응답 재시도 — batteryLevel.value가 0인 동안 재송신
var retries = 0
val retryRunnable = object : Runnable {
override fun run() {
if (!isConnected.value) return
if (batteryLevel.value == 0 && retries < BATTERY_RETRY_MAX) {
retries++
logd { "battery query retry #$retries (no response yet)" }
// 2026-07-08: mtb 응답 진행 중이면 skip — 다음 주기까지 대기 (queue 충돌 방지).
if (!isMtbBusy) sendBatteryQuery()
handler.postDelayed(this, BATTERY_RETRY_DELAY_MS)
}
}
}
batteryRetryTimer = retryRunnable
handler.postDelayed(retryRunnable, BATTERY_RETRY_DELAY_MS)
// 3단계: 30초 주기 polling
val pollRunnable = object : Runnable {
override fun run() {
if (!isConnected.value) return
// 2026-07-08: mtb 응답 진행 중이면 skip — 30초 주기라 다음 tick 에 잡힘.
if (!isMtbBusy) sendBatteryQuery()
handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS)
}
}
batteryTimer = pollRunnable
handler.postDelayed(pollRunnable, BATTERY_POLL_INTERVAL_MS)
}
fun stopBatteryPolling() {
batteryTimer?.let { handler.removeCallbacks(it) }
batteryTimer = null
batteryRetryTimer?.let { handler.removeCallbacks(it) }
batteryRetryTimer = null
}
/**
* RSSI polling — 2 초 주기로 `readRemoteRssi()` 요청 → `onReadRemoteRssi` 콜백이
* `rssi.value` 업데이트. dev-mode UI 오버레이가 이를 관찰한다.
* 실패해도 필수기능 영향 없음 (UC-05).
*/
fun startRssiPolling() {
stopRssiPolling()
val pollRunnable = object : Runnable {
override fun run() {
if (!isConnected.value) return
try { bluetoothGatt?.readRemoteRssi() } catch (_: Exception) {}
handler.postDelayed(this, RSSI_POLL_INTERVAL_MS)
}
}
rssiTimer = pollRunnable
handler.post(pollRunnable)
}
fun stopRssiPolling() {
rssiTimer?.let { handler.removeCallbacks(it) }
rssiTimer = null
}
// ── Watchdog (zombie connection detection, runs on separate thread) ──
private var watchdogJob: Thread? = null
@Volatile private var watchdogRunning = false
private fun startWatchdog() {
stopWatchdog()
lastRxTimestamp = System.currentTimeMillis()
watchdogRunning = true
debugLogger.info("WATCHDOG_STARTED timeout=${watchdogTimeoutMs}ms")
watchdogJob = Thread({
while (watchdogRunning) {
try { Thread.sleep(5000) } catch (_: InterruptedException) { break }
if (!watchdogRunning) break
if (!isConnected.value) break
val silenceMs = System.currentTimeMillis() - lastRxTimestamp
// Idle 10초 이상이면 IMU query로 heartbeat.
// 2026-07-08: sendImuQuery() (msp) → sendImuFifoQuery() (mim). msp 는 신
// firmware 에서 미지원. isMtbBusy 시 skip — mtb 응답 중간에 mim 이 끼어들면
// imuCollector.reset() 이 mtb 의 rim 을 파괴 + GATT queue 혼잡 유발.
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs && !isMtbBusy) {
handler.post { if (!isMtbBusy) sendImuFifoQuery() }
}
if (silenceMs > watchdogTimeoutMs) {
debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect")
// 2026-07-07 fix: watchdog thread 에서 직접 GATT 정리 + characteristic
// null 하면 UI thread 의 sendRaw 와 race — writeCharacteristic 이 이미
// close 중인 GATT 에 접근. 모든 정리를 UI thread (handler.post) 로 위임하여
// single-threaded 로 실행.
watchdogRunning = false
handler.post {
try {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
} catch (_: Exception) {}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
stopFirmwareRetry()
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
stopBatteryPolling()
stopRssiPolling()
rssi.value = null
commandQueue.clear("watchdog-disconnect")
emitConnectionEvent(
com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED,
reason = "watchdog zombie",
)
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
}
break
}
}
}, "BLE-Watchdog").also { it.isDaemon = true; it.start() }
}
private fun stopWatchdog() {
watchdogRunning = false
watchdogJob?.interrupt()
watchdogJob = null
}
fun forceDisconnectAndReconnect() {
stopBatteryPolling()
stopRssiPolling()
rssi.value = null
commandQueue.clear("disconnect")
stopWatchdog()
isConnected.value = false
isServiceReady.value = false
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
}
// ── Auto Reconnect ──
private fun scheduleAutoReconnect() {
logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${reconnectIntervalMs}ms" }
debugLogger.reconnecting(reconnectAttempt + 1, -1, reconnectIntervalMs)
isReconnecting.value = true
cancelReconnectTimer()
val timer = Runnable { attemptAutoReconnect() }
reconnectTimer = timer
handler.postDelayed(timer, reconnectIntervalMs)
}
private var reconnectScanCallback: ScanCallback? = null
@Volatile private var reconnectConnecting = false
private fun attemptAutoReconnect() {
val targetAddress = lastConnectedAddress ?: return
val targetName = debugLogger.deviceName
if (bluetoothAdapter?.isEnabled != true) {
onReconnectionFailed?.invoke()
isReconnecting.value = false
return
}
// max 시도 가드 — 무한 루프 방지. 초과 시 사용자가 명시적으로 재시도하도록.
if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
debugLogger.warn("RECONNECT_GIVE_UP after ${reconnectAttempt} attempts — manual retry required")
isReconnecting.value = false
reconnectAttempt = 0
// 2026-08-04: 사용자에게 명확한 안내. UI (DeviceScanView 등) 가 이 marker 감지 시
// "재연결 실패 · 기기 상태 확인 후 재시도" 안내 표시.
connectionError.value = "RECONNECT_GAVE_UP"
onReconnectionFailed?.invoke()
return
}
reconnectAttempt++
reconnectConnecting = false
logd { "Auto-reconnect #$reconnectAttempt: scanning for $targetName ($targetAddress)" }
debugLogger.info("RECONNECT_SCAN #$reconnectAttempt scanning for $targetName")
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
bluetoothGatt = null
}
// Fresh scan to find the device again
val scanner = bluetoothAdapter.bluetoothLeScanner
if (scanner == null) {
scheduleAutoReconnect()
return
}
// Stop any previous reconnect scan
reconnectScanCallback?.let {
try { scanner.stopScan(it) } catch (_: Exception) {}
}
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (reconnectConnecting) return
val name = try { result.device.name ?: "" } catch (_: Exception) { "" }
val address = result.device.address
if (address == targetAddress || (targetName.isNotEmpty() && name == targetName)) {
reconnectConnecting = true
logd { "Reconnect scan found: $name ($address)" }
debugLogger.info("RECONNECT_FOUND $name ($address) RSSI=${result.rssi}")
try { scanner.stopScan(this) } catch (_: Exception) {}
reconnectScanCallback = null
bluetoothGatt = result.device.connectGatt(
context, false, gattCallback, BluetoothDevice.TRANSPORT_LE
)
}
}
}
reconnectScanCallback = callback
try {
scanner.startScan(callback)
} catch (e: Exception) {
debugLogger.error("RECONNECT_SCAN_FAIL ${e.message}")
scheduleAutoReconnect()
return
}
// Stop scan after 8 seconds → retry next attempt
handler.postDelayed({
try { scanner.stopScan(callback) } catch (_: Exception) {}
reconnectScanCallback = null
if (!isConnected.value) {
debugLogger.warn("RECONNECT_SCAN_TIMEOUT #$reconnectAttempt — device not found, retrying...")
scheduleAutoReconnect()
}
}, 8000)
}
private fun cancelReconnectTimer() {
reconnectTimer?.let { handler.removeCallbacks(it) }
reconnectTimer = null
}
fun cancelAutoReconnect() {
cancelReconnectTimer()
reconnectAttempt = 0
isReconnecting.value = false
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
logd { "onConnectionStateChange: status=$status newState=$newState" }
handler.post {
// Cancel connection timeout
connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null
if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
val wasConnected = isConnected.value
loge { "GATT error: status=$status newState=$newState wasConnected=$wasConnected" }
debugLogger.error("GATT_ERROR status=$status newState=$newState wasConnected=$wasConnected")
stopBatteryPolling()
stopRssiPolling()
rssi.value = null
commandQueue.clear("gatt-error")
emitConnectionEvent(
com.medithings.vesiscan.telemetry.ConnState.FAILED,
reason = "GATT_ERROR status=$status newState=$newState",
)
stopWatchdog()
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
stopFirmwareRetry()
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
gatt.close()
if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) {
debugLogger.disconnected("gatt_error", status)
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
} else if (reconnectAttempt > 0 && lastConnectedAddress != null) {
scheduleAutoReconnect()
} else {
isReconnecting.value = false
// GATT status=5 (INSUFFICIENT_AUTHENTICATION) — FW allowlist 정책상
// 다른 central 과 이미 bond 중이면 peripheral 이 인증 거부.
val failedAddr = gatt.device.address
if (status == 5) {
// GATT_INSUFFICIENT_AUTHENTICATION — FW allowlist bond 충돌
bondConflictAddress.value = failedAddr
connectionError.value = "BOND_CONFLICT:$failedAddr"
debugLogger.warn("BOND_CONFLICT addr=$failedAddr — FW allowlist rejected (status 5)")
} else if (status == 8 || status == 22 || status == 19) {
// status=8 · GATT_CONN_TIMEOUT — link supervision timeout (peripheral 사망)
// status=22 · GATT_CONN_TERMINATE_LOCAL_HOST
// status=19 · GATT_CONN_TERMINATE_PEER_USER — peripheral 이 명시적 disconnect
// → 세 케이스 모두 peripheral 문제 · 사용자에게 재부팅 안내 권장
bondConflictAddress.value = null
connectionError.value = "DEVICE_UNRESPONSIVE:$status"
debugLogger.warn("DEVICE_UNRESPONSIVE status=$status — FW hang/crash · user reboot recommended")
} else {
bondConflictAddress.value = null
connectionError.value = "Connection failed (status $status). Try again."
}
}
return@post
}
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
logd { "Connected to ${gatt.device.address}" }
isReconnecting.value = false
isConnecting.value = false
isUserDisconnect = false
connectionError.value = null
bondConflictAddress.value = null // 연결 성공 시 클리어
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = true, isConnecting = false)
isConnected.value = true
connectedDeviceName.value = try { gatt.device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
// Auto-detect device preset + endian from name
com.medithings.vesiscan.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
piezoCollector.forceBigEndian = connectedDeviceName.value.startsWith("VBT")
logd { "Device preset: ${com.medithings.vesiscan.managers.PiezoHW.presetName}, endian: ${if (piezoCollector.forceBigEndian) "BE" else "LE"} (from '${connectedDeviceName.value}')" }
lastConnectedAddress = gatt.device.address
reconnectAttempt = 0
cancelReconnectTimer()
debugLogger.connected(connectedDeviceName.value, gatt.device.address)
emitConnectionEvent(com.medithings.vesiscan.telemetry.ConnState.CONNECTED)
// 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시
// KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등).
try {
com.medithings.vesiscan.services.KnownDeviceStore.add(
context, gatt.device.address, connectedDeviceName.value
)
} catch (_: Exception) {}
// MTU 247 요청 (reb: 208B 패킷 드롭 방지)
// onMtuChanged 콜백에서 discoverServices 호출
if (!gatt.requestMtu(247)) {
gatt.discoverServices() // MTU 요청 실패 시 바로 진행
}
// 2026-07-07 fix: onConnectionStateChanged(true) 를 여기서 호출하면
// TX/RX characteristic 이 아직 null (onServicesDiscovered 미실행) 이라
// 상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시작해도
// sendRaw 에서 silent drop → RX 실종 → watchdog timeout → forced reconnect
// 좀비 세션의 원인. 콜백은 isServiceReady=true 시점 (onDescriptorWrite)
// 으로 이동.
}
BluetoothProfile.STATE_DISCONNECTED -> {
logd { "Disconnected from ${gatt.device.address}" }
emitConnectionEvent(
com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED,
reason = "gatt state disconnected",
)
isConnecting.value = false
stopBatteryPolling()
stopRssiPolling()
rssi.value = null
commandQueue.clear("state-disconnected")
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
stopFirmwareRetry()
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
firmwareVersion.value = ""
hardwareVersion.value = ""
serialNumber.value = ""
batteryLevel.value = 0
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
onConnectionStateChanged?.invoke(false)
if (!isUserDisconnect && lastConnectedAddress != null) {
debugLogger.disconnected("unexpected", status)
onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
}
isUserDisconnect = false
gatt.close()
}
}
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
logd { "MTU changed: $mtu (status=$status)" }
handler.post { debugLogger.info("MTU=$mtu") }
gatt.discoverServices()
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
logd { "onServicesDiscovered: status=$status" }
if (status != BluetoothGatt.GATT_SUCCESS) {
handler.post { connectionError.value = "Service discovery failed (status $status)" }
return
}
val service = gatt.getService(SERVICE_UUID)
if (service == null) {
loge { "NUS service not found!" }
handler.post { connectionError.value = "Device service not found" }
return
}
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
logd { "TX=${txCharacteristic != null}, RX=${rxCharacteristic != null}" }
rxCharacteristic?.let { rx ->
gatt.setCharacteristicNotification(rx, true)
val descriptor = rx.getDescriptor(CCCD_UUID)
if (descriptor != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
} else {
logw { "CCCD descriptor not found for RX characteristic" }
handler.post {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
onConnectionStateChanged?.invoke(true)
startBatteryPolling()
startRssiPolling()
startWatchdog()
logd { "Service ready (no CCCD)" }
}
}
}
} ?: run {
handler.post {
if (txCharacteristic != null) {
isServiceReady.value = true
onConnectionStateChanged?.invoke(true)
startBatteryPolling()
startRssiPolling()
startWatchdog()
logd { "Service ready (no RX)" }
}
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" }
if (descriptor.uuid == CCCD_UUID) {
handler.post {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
// 2026-07-07 fix: 여기서 상위에 알림 — TX/RX 준비 완료 시점. 이전에는
// STATE_CONNECTED 즉시 호출해서 아직 characteristic null 인 상태로
// 상위가 TX 시도 → silent drop → 좀비 세션.
onConnectionStateChanged?.invoke(true)
// Connection priority HIGH 요청: peripheral이 수락하면 interval ~15ms로 협상되어
// Central Link Layer ACK 속도 ↑ → FW SoftDevice TX queue 포화 빈도 ↓
val priorityOk = try {
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
} catch (_: Exception) { false }
logd { "requestConnectionPriority(HIGH) → $priorityOk" }
debugLogger.info("CONN_PRIORITY HIGH requested (ok=$priorityOk)")
com.medithings.vesiscan.services.AdcCsvLogger.newSession()
com.medithings.vesiscan.services.BleForegroundService.start(context)
// Watchdog 은 즉시 시작 (freeze 감지 필요).
startWatchdog()
// 2026-08-04: 첫 TX 를 INITIAL_TX_DELAY_MS 지연.
// R100 등 신규 HW 는 boot 후 응답까지 여러 초 걸림 → 즉시 발사 시
// 초기 msn/mid 가 CMDQ timeout 반복 → 사용자에게 노출.
fwRetryCount = 0
handler.postDelayed({
if (!isConnected.value) return@postDelayed
startBatteryPolling()
startRssiPolling()
sendDeviceInfoQuery()
// 4초 후에도 펌웨어 미수신이면 mfv? fallback.
fwFallbackTimer?.let { handler.removeCallbacks(it) }
val fwFallback = Runnable {
if (firmwareVersion.value.isEmpty() && isConnected.value) {
debugLogger.info("FW fallback: sending mfv?")
sendFirmwareVersionQuery()
scheduleFirmwareRetry()
}
}
fwFallbackTimer = fwFallback
handler.postDelayed(fwFallback, 4_000)
}, INITIAL_TX_DELAY_MS)
logd { "Service ready! (CCCD written) · initial TX in ${INITIAL_TX_DELAY_MS}ms" }
}
} else {
loge { "CCCD write failed with status: $status — retrying in 500ms" }
// GATT 133 등 일시적 오류 → 500ms 후 재시도.
// 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지.
cccdRetryTimer?.let { handler.removeCallbacks(it) }
val cccdRetry = Runnable {
try {
val rx = rxCharacteristic
if (rx != null) {
gatt.setCharacteristicNotification(rx, true)
val cccd = rx.getDescriptor(CCCD_UUID)
if (cccd != null) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(cccd)
}
logd { "CCCD retry sent" }
}
}
} catch (e: Exception) {
connectionError.value = "Notification setup failed (status $status)"
}
}
cccdRetryTimer = cccdRetry
handler.postDelayed(cccdRetry, 500)
}
}
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) {
if (value.size < 4) return
processReceivedData(value)
}
}
}
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) {
@Suppress("DEPRECATION")
val data = characteristic.value ?: return
if (data.size < 4) return
processReceivedData(data)
}
}
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
handler.post {
debugLogger.rssi(rssi)
this@BleManager.rssi.value = rssi
}
}
}
}
private fun processReceivedData(data: ByteArray) {
val now = System.currentTimeMillis()
lastRxTimestamp = now
// 2026-07-07 fix: lastBleRxAt 을 processReceivedData 진입점에서 무조건 갱신.
// 이전에는 reb: (piezo 측정) 응답 케이스에만 세팅 → auto scan 꺼진 도넛차트
// idle 상태 (mim 폴링만) 에서는 rim 응답이 매초 오는데도 lastBleRxAt = 0.
// FirmwareWarningBanner 의 deviceAlive 판정이 false → 어떤 이유로 timedOut=true
// 되면 "기기 응답 없음" false-positive 배너 노출.
lastBleRxAt.value = now
// Raw BLE capture (디버그 — 사용자가 토글로 enable 했을 때만 writes)
com.medithings.vesiscan.services.RawBleLogger.write(data)
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
logd { "Received: $prefix (${data.size} bytes)" }
when (prefix) {
"rta:", "rta!" -> {
debugLogger.rx("rta", data.size, "piezo power ON")
onNirsPowerOnReceived?.invoke()
}
"rsh:", "rsh!", "rqq:", "rqq!" -> {
debugLogger.rx(prefix.take(3), data.size, "sensor activated")
onNirsSensorActivated?.invoke()
}
"rag:", "rag!" -> {
debugLogger.rx("rag", data.size, "gain calibration (${(data.size - 6) / 2} values)")
onNirsMagReceived?.invoke(data)
}
"rcj:", "rcj!" -> {
debugLogger.rx("rcj", data.size, "MCJ packet")
onNirsMcjReceived?.invoke(data)
}
"rpa:" -> {
debugLogger.rx("rpa", data.size, "piezo ack")
onPiezoDataReceived?.invoke(data)
}
"rer:" -> {
debugLogger.rx("rer", data.size, "preliminary header")
}
"reb:" -> {
// 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10
val samples = ((data.size - 10) / 2).coerceAtLeast(0)
val chSession = if (data.size > 4) data[4].toInt() and 0xFF else -1
val chNum = if (data.size > 5) data[5].toInt() and 0xFF else -1
debugLogger.rx("reb", data.size, "session=$chSession ch=$chNum ${samples}samples")
piezoCollector.addPacket(data)
lastBleRxAt.value = System.currentTimeMillis() // 기기 alive 마크
}
"rec:" -> {
// 저 MTU chunk (2026-07-21 FW+): tag 4 + ch_info 2 + off 2 + total 2 + chunk 2 + ADC + CRC 2
// → ADC bytes = size − 14. 첫/마지막 chunk 마다 이 케이스로 진입.
val chSession = if (data.size > 4) data[4].toInt() and 0xFF else -1
val chNum = if (data.size > 5) data[5].toInt() and 0xFF else -1
val off = if (data.size >= 8) ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) else -1
val total = if (data.size >= 10) ((data[8].toInt() and 0xFF) shl 8) or (data[9].toInt() and 0xFF) else -1
val chunk = if (data.size >= 12) ((data[10].toInt() and 0xFF) shl 8) or (data[11].toInt() and 0xFF) else -1
debugLogger.rx("rec", data.size, "session=$chSession ch=$chNum off=$off chunk=$chunk/$total")
piezoCollector.addPacket(data)
lastBleRxAt.value = System.currentTimeMillis()
}
"red:" -> {
debugLogger.rx("red", data.size, "continuation")
piezoCollector.addPacket(data)
}
"ree:", "raa:" -> {
val tag = prefix.take(3)
debugLogger.rx(tag, data.size, if (tag == "raa") "all-ch complete" else "single-ch end")
piezoCollector.addPacket(data)
// raa 성공 → 연속 timeout 카운터 리셋 (mtb 완료 UI 관찰).
// 3초 timeout 은 CommandQueue 가 소유 (기존 mtbTimeoutRunnable 은 dead code · 유지만).
if (tag == "raa" && consecutiveMtbTimeouts.value != 0) {
consecutiveMtbTimeouts.value = 0
}
}
"rsn:" -> {
if (data.size >= 6) {
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
val pct = when {
millivolts <= 3500 -> 0
millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200).coerceIn(0, 5)
else -> (5 + (millivolts - 3700) * 95 / 400).coerceIn(5, 100)
}
if (pct <= batteryLevel.value || batteryLevel.value == 0) {
batteryLevel.value = pct
}
debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)")
emitBatteryEvent(pct)
}
}
"rid:" -> {
// 명세: [tag 4B] [HW 12B] [Serial 12B] [FW 12B] [crc 2B] = 42B.
// 각 12B 필드는 ASCII 가변 길이 + NULL/공백/제어문자 padding.
// (구) whitespace split 방식은 padding 이 space 가 아닐 때 깨졌음.
fun field(offset: Int): String {
val end = (offset + 12).coerceAtMost(data.size - 2) // CRC 2B 앞까지
if (end <= offset) return ""
return String(data, offset, end - offset, Charsets.US_ASCII)
.replace(Regex("[\\x00-\\x1F\\x7F]"), "").trim()
}
val hw = field(4)
val sn = field(16)
val fw = field(28)
val info = "$hw $sn $fw".trim()
debugLogger.rx("rid", data.size, "device_info=$info")
debugLogger.info("DEVICE_INFO hw=$hw sn=$sn fw=$fw")
handler.post {
if (hw.isNotEmpty()) hardwareVersion.value = hw
if (fw.isNotEmpty()) firmwareVersion.value = fw
if (sn.isNotEmpty()) serialNumber.value = sn
}
debugLogger.deviceInfo(fw, hw, sn)
}
"rfv:" -> {
// 명세: [tag 4B] [FW ASCII N B] [crc 2B] — FW 만 별도 쿼리(mfv?) 응답.
// mid? 가 응답 안 하는 펌웨어 fallback 용.
if (data.size > 6) {
val fw = String(data, 4, data.size - 6, Charsets.US_ASCII)
.replace(Regex("[\\x00-\\x1F\\x7F]"), "").trim()
debugLogger.rx("rfv", data.size, "fw=$fw")
if (fw.isNotEmpty()) handler.post { firmwareVersion.value = fw }
}
}
"rls:" -> {
val state = if (data.size >= 5) data[4].toInt() and 0xFF else -1
debugLogger.rx("rls", data.size, "led_state=$state")
}
"rbb:" -> {
debugLogger.rx("rbb", data.size, "full measurement header (battery+IMU+temp)")
onMbbHeaderReceived?.invoke(data)
piezoCollector.addPacket(data)
}
"rsp:" -> {
// msp 명령 응답 — IMU 데이터. format 불명확이라 defensive parsing.
val hex = data.take(kotlin.math.min(data.size, 24)).joinToString(" ") { "%02X".format(it) }
debugLogger.rx("rsp", data.size, "IMU data hex=$hex")
android.util.Log.d("ImuDbg", "rsp RX len=${data.size} hex=$hex")
imuCollector.parseRsp(data)
onImuReceived?.invoke(data)
}
// ── 공통 에러 응답 (xlsx §공통 프레임) — CommandQueue 가 pending 완료 처리 ──
// rxx: Unknown command · rxd: Disabled · rxn: NULL handler · rxc: CRC fail · rxs: Too short
"rxx:", "rxd:", "rxn:", "rxc:", "rxs:" -> {
val tag = prefix.take(3)
val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?"
val reason = when (tag) {
"rxx" -> "unknown_cmd"
"rxd" -> "disabled_cmd"
"rxn" -> "null_handler"
"rxc" -> "crc_fail"
"rxs" -> "too_short"
else -> "err"
}
debugLogger.rx(tag, data.size, "$reason: $echoCmd")
}
"rim:" -> {
val num = if (data.size >= 6) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else 0
debugLogger.rx("rim", data.size, "IMU ${num} samples")
imuCollector.parseRim(data)
}
"ric:" -> {
// 저 MTU IMU chunk (2026-07-21 FW+): tag 4 + off 2 + total 2 + chunk 2 + IMU 12·chunk + CRC 2
val off = if (data.size >= 6) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else -1
val total = if (data.size >= 8) ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) else -1
val chunk = if (data.size >= 10) ((data[8].toInt() and 0xFF) shl 8) or (data[9].toInt() and 0xFF) else -1
debugLogger.rx("ric", data.size, "IMU off=$off chunk=$chunk/$total")
imuCollector.parseRic(data)
}
else -> {
val hex = data.take(8).joinToString(" ") { "%02X".format(it) }
debugLogger.rx("???", data.size, "unknown prefix='$prefix' hex=$hex")
logw { "Unknown prefix: '$prefix' hex=$hex" }
}
}
// ── CommandQueue hook — 파서가 collector 상태 갱신한 뒤 완료 판정 ──
// 3-char 태그 (예: "raa", "rim", "rxc") 로 현재 pending 명령의 isDone 실행.
// True 면 pending 해제 + 큐에 대기 중이던 다음 명령 dispatch.
if (data.size >= 4) {
val tag3 = prefix.take(3)
commandQueue.onResponseTag(tag3)
}
}
}