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