c5dd0fdc3e
## 앞선 커밋이 세 군데 틀렸다 2026-09-02 펌웨어팀 스펙을 받아 바로잡는다. 1. **인자가 2개가 아니라 5개다.** mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B avg·delay_us·samples 를 안 보내면 길이 부족으로 거부된다(freq=0xFFFF 응답). 2. **주파수 값이 1/2 가 아니라 0/5 다.** 0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz. 앞선 커밋의 가정(1.8→1, 2.3→2)은 둘 다 틀렸다 — 실제로는 2.0 과 2.3 을 재게 된다. 가정을 파일명에 남겨 둔 안전장치가 없었다면 못 알아챌 뻔했다. 3. **응답을 확인하지 않고 있었다.** 이게 가장 위험하다 — 아래 참고. ## 설정 실패는 조용하다 — 그래서 반드시 확인한다 실패해도 `rcs:` 는 온다. 구분은 freq 값이다: · 0xFFFF — 파라미터 범위 초과 또는 데이터 길이 부족 · 0xFFFD — 검증은 통과했으나 NVS 저장 실패 거부돼도 프로브는 **옛 설정으로 측정을 계속한다.** 확인하지 않으면 파일에는 요청한 값이 적힌 채 다른 조건의 데이터가 쌓인다 — 잘못된 데이터가 정상처럼 보이는, 임상에서 가장 나쁜 결과다. 그래서 조합마다 echo 를 받아 **요청한 다섯 값과 전부 일치할 때만** 측정한다. 불일치·무응답이면 그 조합을 통째로 건너뛰고 run json 에 `skipped_reason` 을 남기며 화면에 빨갛게 띄운다. 비는 편이 틀린 것보다 낫다. ## 고정 파라미터 프로토콜이 바꾸는 것은 주파수·cycle 뿐이다. 나머지 셋은 모든 조합에서 같아야 비교가 성립하므로 `HospitalFixedParams` 한 곳에 둔다 — avg 10 · delay_us 10 · samples 100 (펌웨어팀 예시값, samples 는 앱 채널 버퍼 100 과도 일치). 1.8MHz·c3 6D 63 73 3F 00 00 00 03 00 0A 00 0A 00 64 3C 4A 2.3MHz·c7 6D 63 73 3F 00 05 00 07 00 0A 00 0A 00 64 36 FC ## ⚠ 설정이 프로브에 영구 저장된다 (NVS) 전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정 화면도 마지막 조합(2.3MHz·cycle 7)으로 동작한다.** run json 에 남기고 화면에도 명시했다. 임상 후 원래 값으로 되돌릴지는 별도 결정이 필요하다 — 공장 기본값을 모른다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1853 lines
86 KiB
Kotlin
1853 lines
86 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
|
||
}
|
||
|
||
/**
|
||
* 프로브에 저장된 측정 파라미터 (`rcs:` echo).
|
||
*
|
||
* [freq] 는 정상이면 0~5 지만 실패 시 오류 코드가 실려 온다 — [error] 참고.
|
||
*/
|
||
data class PiezoConfig(
|
||
val freq: Int,
|
||
val cycles: Int,
|
||
val avg: Int,
|
||
val delayUs: Int,
|
||
val samples: Int,
|
||
) {
|
||
/** 펌웨어가 알려 주는 실패 사유. 정상이면 null. */
|
||
val error: String?
|
||
get() = when (freq) {
|
||
0xFFFF -> "파라미터 범위 초과 또는 데이터 길이 부족"
|
||
0xFFFD -> "NVS 저장 실패"
|
||
else -> null
|
||
}
|
||
|
||
/** 요청한 값이 그대로 저장됐는지. 하나라도 다르면 그 설정으로 측정하면 안 된다. */
|
||
fun matches(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int): Boolean =
|
||
error == null && this.freq == freq && this.cycles == cycles &&
|
||
this.avg == avg && this.delayUs == delayUs && this.samples == samples
|
||
}
|
||
|
||
@SuppressLint("MissingPermission")
|
||
class BleManager private constructor(private val context: Context) {
|
||
|
||
companion object {
|
||
private const val TAG = "BleManager"
|
||
/** 정상 측정에 필요한 최소 펌웨어 빌드 번호 (mtb? throttle/MTU 가정과 일치). */
|
||
const val MIN_FIRMWARE_BUILD = 118
|
||
private inline fun logd(msg: () -> String) { if (BuildConfig.DEBUG) Log.d(TAG, msg()) }
|
||
private inline fun logw(msg: () -> String) { if (BuildConfig.DEBUG) Log.w(TAG, msg()) }
|
||
private inline fun loge(msg: () -> String) { if (BuildConfig.DEBUG) Log.e(TAG, msg()) }
|
||
|
||
@Volatile
|
||
private var instance: BleManager? = null
|
||
fun getInstance(context: Context): BleManager =
|
||
instance ?: synchronized(this) {
|
||
instance ?: BleManager(context.applicationContext).also { instance = it }
|
||
}
|
||
|
||
/** Logger 같은 context-free 컴포넌트에서 BLE state 읽기용. instance 생성 후에만 non-null. */
|
||
val current: BleManager? get() = instance
|
||
|
||
val SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
||
val TX_CHAR_UUID: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
|
||
val RX_CHAR_UUID: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
|
||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||
}
|
||
|
||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||
private var bluetoothLeScanner: BluetoothLeScanner? = null
|
||
@Volatile private var bluetoothGatt: BluetoothGatt? = null
|
||
@Volatile private var txCharacteristic: BluetoothGattCharacteristic? = null
|
||
@Volatile private var rxCharacteristic: BluetoothGattCharacteristic? = null
|
||
private val handler = Handler(Looper.getMainLooper())
|
||
val debugLogger = com.medithings.vesiscan.services.BleDebugLogger.getInstance(context)
|
||
private var scanTimer: Runnable? = null
|
||
private var batteryTimer: Runnable? = null
|
||
private var batteryRetryTimer: Runnable? = null
|
||
private val BATTERY_POLL_INTERVAL_MS = 30_000L // 정상 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)))
|
||
}
|
||
|
||
/** `rcs:` echo — 프로브에 **실제로 저장된** 측정 파라미터. 아직 못 받았으면 null. */
|
||
val piezoConfigEcho = mutableStateOf<PiezoConfig?>(null)
|
||
|
||
/**
|
||
* 측정 파라미터 (`mcs?`). 프로브 NVS 에 저장되어 전원을 껐다 켜도 유지된다.
|
||
*
|
||
* 2026-09-02 펌웨어팀 확인 — 이 앱이 여태 쓰던 `mpa?`(piezo power ON)는 설정 명령이
|
||
* 아니었다. 즉 지금까지 주파수·cycle 을 한 번도 바꾼 적이 없다.
|
||
*
|
||
* 요청 mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B
|
||
* 응답 rcs: 같은 구성으로 저장된 값을 echo
|
||
*
|
||
* 범위: freq 0~5(0=1.8 … 5=2.3MHz) · cycles 3~7 · avg 1~10 · delay_us 0~50 ·
|
||
* samples 80~117.
|
||
*
|
||
* ⚠ **응답을 반드시 확인해야 한다.** 실패해도 `rcs:` 는 오고, 그때도 측정은 옛
|
||
* 설정으로 계속 돈다. 확인하지 않으면 파일에는 요청한 값이 적힌 채 다른 설정의
|
||
* 데이터가 쌓인다. [piezoConfigEcho] 를 보고 요청과 같은지 대조할 것.
|
||
*/
|
||
fun sendPiezoConfig(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int) {
|
||
piezoConfigEcho.value = null
|
||
sendRaw(CRC16.buildCommandBE("mcs", intArrayOf(freq, cycles, avg, delayUs, samples)))
|
||
}
|
||
|
||
fun sendPiezoStop() {
|
||
sendRaw(CRC16.buildCommandASCII("mps", " "))
|
||
}
|
||
|
||
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
|
||
// mec: reb×1 → raa. 종료 = raa.
|
||
commandQueue.enqueue(QueuedCommand(
|
||
data = CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)),
|
||
label = "mec burst",
|
||
isDone = { it == "raa" },
|
||
))
|
||
}
|
||
|
||
fun sendAllChannels(mode: Int = 0): Boolean {
|
||
if (!canSendMaa("sendAllChannels")) return false
|
||
lastMaaSentMs = System.currentTimeMillis()
|
||
piezoCollector.startMultiChannel(6)
|
||
// maa: reb×6 → raa. 종료 = raa.
|
||
commandQueue.enqueue(QueuedCommand(
|
||
data = CRC16.buildCommandASCII("maa", " "),
|
||
label = "maa 6ch",
|
||
isDone = { it == "raa" },
|
||
))
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 정렬 모드 측정 (mtb?) — Piezo 6채널 + IMU 동시 캡처.
|
||
* 응답: reb: × 6 + raa: + rim: (각각 piezoCollector / imuCollector가 처리)
|
||
*
|
||
* canSendMaa() 게이트는 maa와 공유 (lastMaaSentMs 같이 사용).
|
||
*/
|
||
fun sendMtb(): Boolean {
|
||
if (!canSendMaa("sendMtb")) return false
|
||
lastMaaSentMs = System.currentTimeMillis()
|
||
piezoCollector.startMultiChannel(6)
|
||
imuCollector.reset()
|
||
// mtb: reb×6 → raa → rim (또는 ric 분할). 종료 = piezo raa 완료 AND imu 샘플 도착.
|
||
// parseRic 는 마지막 chunk 도착 시에만 samples 를 채움 → 중간 ric 은 자연스레 skip.
|
||
commandQueue.enqueue(QueuedCommand(
|
||
data = CRC16.buildCommandASCII("mtb", " "),
|
||
label = "mtb 6ch+imu",
|
||
isDone = { tag ->
|
||
(tag == "rim" || tag == "ric" || tag == "raa") &&
|
||
piezoCollector.isComplete &&
|
||
imuCollector.samples.isNotEmpty()
|
||
},
|
||
timeoutMs = MTB_TIMEOUT_MS,
|
||
))
|
||
return true
|
||
}
|
||
|
||
// ── Common Commands ──
|
||
|
||
fun sendBatteryQuery() {
|
||
// mid?/mtb?/maa? 등 다른 단순 query와 동일한 ASCII " " 형식 — BE [0]은
|
||
// 펌웨어가 응답 안 함이 확인됨 (rsn: 자체가 안 옴, 2026-06-08).
|
||
sendRaw(CRC16.buildCommandASCII("msn", " "))
|
||
}
|
||
|
||
fun sendDeviceInfoQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mid", " "))
|
||
}
|
||
|
||
/** mfv? — FW Version only. mid? 응답 안 오는 펌웨어용 fallback. */
|
||
fun sendFirmwareVersionQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mfv", " "))
|
||
}
|
||
|
||
/**
|
||
* FW query 자동 재시도 (2026-08-04 · GUARDIAN §Rev2 후속).
|
||
*
|
||
* 배경: 연결 직후 mid? (t=0) → mfv? fallback (t=4s) 만으로도 응답 없으면
|
||
* firmwareVersion 이 empty 로 남고, 8초 후 FirmwareWarningBanner 의
|
||
* `timedOut = true` 로 배너 노출. Alignment idle 처럼 트래픽이 적은
|
||
* 화면에서 특히 눈에 띔.
|
||
*
|
||
* 신규 로직: `scheduleFirmwareRetry()` 호출 시점부터 5초 간격으로 mid?/mfv?
|
||
* 를 교차 발사. 최대 3회. 응답 오면 (firmwareVersion 채워짐) 즉시 중단.
|
||
*
|
||
* 실패 시: 3회 모두 응답 없음 → 재시도 종료. 배너는 그때 뜸 (진짜 문제 상황).
|
||
*/
|
||
fun scheduleFirmwareRetry() {
|
||
fwRetryTimer?.let { handler.removeCallbacks(it) }
|
||
val runnable = object : Runnable {
|
||
override fun run() {
|
||
// 성공 · 연결 끊김 · 최대 시도 도달 → 중단
|
||
if (firmwareVersion.value.isNotEmpty()) {
|
||
debugLogger.info("FW retry: success (fw=${firmwareVersion.value})")
|
||
fwRetryTimer = null
|
||
return
|
||
}
|
||
if (!isConnected.value) {
|
||
fwRetryTimer = null
|
||
return
|
||
}
|
||
if (fwRetryCount >= FW_RETRY_MAX) {
|
||
debugLogger.warn("FW retry: gave up after $FW_RETRY_MAX attempts")
|
||
fwRetryTimer = null
|
||
return
|
||
}
|
||
fwRetryCount++
|
||
// mid? / mfv? 교차 (홀수=mid, 짝수=mfv). 다른 명령이 서로 다른 FW 지원.
|
||
if (fwRetryCount % 2 == 1) {
|
||
debugLogger.info("FW retry #$fwRetryCount: mid?")
|
||
sendDeviceInfoQuery()
|
||
} else {
|
||
debugLogger.info("FW retry #$fwRetryCount: mfv?")
|
||
sendFirmwareVersionQuery()
|
||
}
|
||
handler.postDelayed(this, FW_RETRY_INTERVAL_MS)
|
||
}
|
||
}
|
||
fwRetryTimer = runnable
|
||
handler.postDelayed(runnable, FW_RETRY_INTERVAL_MS)
|
||
}
|
||
|
||
private fun stopFirmwareRetry() {
|
||
fwRetryTimer?.let { handler.removeCallbacks(it) }
|
||
fwRetryTimer = null
|
||
fwRetryCount = 0
|
||
}
|
||
|
||
fun sendLedMode(state: Int) {
|
||
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
|
||
}
|
||
|
||
// 2026-07-08: sendImuQuery() (msp) 완전 제거. 신 firmware 는 mim (FIFO 15 sample) 만 사용.
|
||
// Watchdog heartbeat 도 sendImuFifoQuery (mim) 로 통일.
|
||
// ※ debugLogger 명령 매핑 (msp → "IMU query") 은 legacy 응답 (rsp:) 파싱용으로 유지.
|
||
|
||
/**
|
||
* mtb 응답 진행 중 여부 — Battery/IMU 폴링이 mtb 응답 stream (reb×6 + raa + rim) 중간에
|
||
* 끼어들면 firmware GATT queue 가 꼬여 응답 실종 → freeze 유발.
|
||
* 이 property 를 batteryTimer / mim polling / watchdog heartbeat 앞에서 확인해 skip.
|
||
*/
|
||
val isMtbBusy: Boolean
|
||
get() = piezoCollector.isMultiChannel && !piezoCollector.isComplete
|
||
|
||
/**
|
||
* mtb 응답 3초 timeout 후 갱신되는 마지막 timeout 시각. 상위 UI 가 관찰해서
|
||
* "기기 응답 지연" 배너 표시. 0 = 아직 timeout 없음.
|
||
*/
|
||
val lastMtbTimeoutAt = mutableStateOf(0L)
|
||
/** mtb 연속 timeout 카운터. 3회 이상 시 재연결 유도. */
|
||
val consecutiveMtbTimeouts = mutableStateOf(0)
|
||
private var mtbTimeoutRunnable: Runnable? = null
|
||
private val MTB_TIMEOUT_MS = 3000L
|
||
|
||
/**
|
||
* IMU FIFO 폴링 (2026-07-03 펌웨어 신규 mim?):
|
||
* - piezo burst 없음 (무음)
|
||
* - IMU 15 sample (300ms window) 를 rim: 프레임으로 반환
|
||
* - 응답 스키마는 mtb? 의 rim: 와 동일 → imuCollector.parseRim + onComplete 재사용
|
||
* idle 폴링에서 walking detector 의 sliding window 를 채우기 위해 사용.
|
||
* mtb 진행 중에는 imuCollector.reset() 이 mtb 의 rim 을 파괴하므로 caller 가 gating 필수.
|
||
*/
|
||
fun sendImuFifoQuery() {
|
||
imuCollector.reset()
|
||
// mim: rim: (또는 ric 분할). 종료 = imu 샘플 채워짐 (마지막 rim 또는 마지막 ric).
|
||
commandQueue.enqueue(QueuedCommand(
|
||
data = CRC16.buildCommandASCII("mim", " "),
|
||
label = "mim IMU",
|
||
isDone = { tag ->
|
||
(tag == "rim" || tag == "ric") && imuCollector.samples.isNotEmpty()
|
||
},
|
||
))
|
||
}
|
||
|
||
/** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */
|
||
fun sendFullMeasurement() {
|
||
piezoCollector.startMultiChannel(6)
|
||
// mbb: rbb → reb×6 → raa. 종료 = raa (piezo 완료).
|
||
commandQueue.enqueue(QueuedCommand(
|
||
data = CRC16.buildCommandASCII("mbb", " "),
|
||
label = "mbb full",
|
||
isDone = { it == "raa" && piezoCollector.isComplete },
|
||
))
|
||
}
|
||
|
||
/**
|
||
* 6채널 측정 명령(maa) 송신 가능 여부:
|
||
* (A) 이전 응답이 아직 진행 중(isMultiChannel && !isComplete)이면 차단.
|
||
* 단 3초 넘게 응답이 안 오면 강제 통과(영영 deadlock 방지).
|
||
* (B) 마지막 송신으로부터 500ms 미만이면 차단(펌웨어 burst 방어).
|
||
*
|
||
* FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 조합에서 한 사이클 응답이
|
||
* 약 280~420ms이므로, 500ms = 응답 평균 + 안전 마진 ~80~220ms.
|
||
*/
|
||
private var lastMaaSentMs = 0L
|
||
private fun canSendMaa(caller: String): Boolean {
|
||
val now = System.currentTimeMillis()
|
||
val collectorBusy = piezoCollector.isMultiChannel && !piezoCollector.isComplete
|
||
val sinceLast = now - lastMaaSentMs
|
||
if (collectorBusy) {
|
||
if (sinceLast < 3000) {
|
||
android.util.Log.w("BleManager", "maa BUSY [$caller] — prev response in progress (${sinceLast}ms)")
|
||
return false
|
||
}
|
||
android.util.Log.w("BleManager", "maa FORCE [$caller] — prev incomplete after ${sinceLast}ms, abandoning")
|
||
}
|
||
if (sinceLast < 500) {
|
||
android.util.Log.w("BleManager", "maa THROTTLED [$caller] — ${sinceLast}ms since last")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
fun sendChannelsOnly(): Boolean {
|
||
if (!canSendMaa("sendChannelsOnly")) return false
|
||
lastMaaSentMs = System.currentTimeMillis()
|
||
piezoCollector.startMultiChannel(6)
|
||
// maa: reb×6 → raa. 종료 = raa (piezo 완료).
|
||
commandQueue.enqueue(QueuedCommand(
|
||
data = CRC16.buildCommandASCII("maa", " "),
|
||
label = "maa 6ch",
|
||
isDone = { it == "raa" && piezoCollector.isComplete },
|
||
))
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 배터리 polling 시작.
|
||
*
|
||
* 이전엔 "1회 송신만" 이었고, 그 한 번이 큐 경합/타이밍 문제로 손실되면 배터리가
|
||
* 영원히 0%로 표시되는 이슈가 있었음. 이제 세 단계로 보장:
|
||
* 1. 즉시 1회 송신 (best case)
|
||
* 2. 3초 내 응답 없으면(batteryLevel.value == 0) 최대 2회 재송신 (첫 응답 누락 보강)
|
||
* 3. 30초 주기 정상 polling (충전 반영 + 장기 추적)
|
||
*
|
||
* BLE 끊김 / 측정 시작 시 stopBatteryPolling()으로 정리.
|
||
*/
|
||
fun startBatteryPolling() {
|
||
stopBatteryPolling() // 기존 타이머 안전 정리
|
||
sendBatteryQuery() // 1단계: 즉시 1회
|
||
|
||
// 2단계: 첫 응답 재시도 — batteryLevel.value가 0인 동안 재송신
|
||
var retries = 0
|
||
val retryRunnable = object : Runnable {
|
||
override fun run() {
|
||
if (!isConnected.value) return
|
||
if (batteryLevel.value == 0 && retries < BATTERY_RETRY_MAX) {
|
||
retries++
|
||
logd { "battery query retry #$retries (no response yet)" }
|
||
// 2026-07-08: mtb 응답 진행 중이면 skip — 다음 주기까지 대기 (queue 충돌 방지).
|
||
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()
|
||
|
||
// 위와 같은 이유로 항상 남긴다 (gatt_error 경로).
|
||
if (wasConnected) {
|
||
debugLogger.disconnected(
|
||
if (isUserDisconnect) "user_gatt_error" else "gatt_error",
|
||
status,
|
||
)
|
||
}
|
||
|
||
if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) {
|
||
onUnexpectedDisconnect?.invoke()
|
||
scheduleAutoReconnect()
|
||
} else if (reconnectAttempt > 0 && lastConnectedAddress != null) {
|
||
scheduleAutoReconnect()
|
||
} else {
|
||
isReconnecting.value = false
|
||
// GATT status=5 (INSUFFICIENT_AUTHENTICATION) — FW allowlist 정책상
|
||
// 다른 central 과 이미 bond 중이면 peripheral 이 인증 거부.
|
||
val failedAddr = gatt.device.address
|
||
if (status == 5) {
|
||
// GATT_INSUFFICIENT_AUTHENTICATION — FW allowlist bond 충돌
|
||
bondConflictAddress.value = failedAddr
|
||
connectionError.value = "BOND_CONFLICT:$failedAddr"
|
||
debugLogger.warn("BOND_CONFLICT addr=$failedAddr — FW allowlist rejected (status 5)")
|
||
} else if (status == 8 || status == 22 || status == 19) {
|
||
// status=8 · GATT_CONN_TIMEOUT — link supervision timeout (peripheral 사망)
|
||
// status=22 · GATT_CONN_TERMINATE_LOCAL_HOST
|
||
// status=19 · GATT_CONN_TERMINATE_PEER_USER — peripheral 이 명시적 disconnect
|
||
// → 세 케이스 모두 peripheral 문제 · 사용자에게 재부팅 안내 권장
|
||
bondConflictAddress.value = null
|
||
connectionError.value = "DEVICE_UNRESPONSIVE:$status"
|
||
debugLogger.warn("DEVICE_UNRESPONSIVE status=$status — FW hang/crash · user reboot recommended")
|
||
} else {
|
||
bondConflictAddress.value = null
|
||
connectionError.value = "Connection failed (status $status). Try again."
|
||
}
|
||
}
|
||
return@post
|
||
}
|
||
|
||
when (newState) {
|
||
BluetoothProfile.STATE_CONNECTED -> {
|
||
logd { "Connected to ${gatt.device.address}" }
|
||
isReconnecting.value = false
|
||
isConnecting.value = false
|
||
isUserDisconnect = false
|
||
connectionError.value = null
|
||
bondConflictAddress.value = null // 연결 성공 시 클리어
|
||
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = true, isConnecting = false)
|
||
isConnected.value = true
|
||
connectedDeviceName.value = try { gatt.device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
||
// Auto-detect device preset + endian from name
|
||
com.medithings.vesiscan.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
|
||
piezoCollector.forceBigEndian = connectedDeviceName.value.startsWith("VBT")
|
||
logd { "Device preset: ${com.medithings.vesiscan.managers.PiezoHW.presetName}, endian: ${if (piezoCollector.forceBigEndian) "BE" else "LE"} (from '${connectedDeviceName.value}')" }
|
||
|
||
lastConnectedAddress = gatt.device.address
|
||
reconnectAttempt = 0
|
||
cancelReconnectTimer()
|
||
debugLogger.connected(connectedDeviceName.value, gatt.device.address)
|
||
emitConnectionEvent(com.medithings.vesiscan.telemetry.ConnState.CONNECTED)
|
||
|
||
// 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시
|
||
// KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등).
|
||
try {
|
||
com.medithings.vesiscan.services.KnownDeviceStore.add(
|
||
context, gatt.device.address, connectedDeviceName.value
|
||
)
|
||
} catch (_: Exception) {}
|
||
|
||
// MTU 247 요청 (reb: 208B 패킷 드롭 방지)
|
||
// onMtuChanged 콜백에서 discoverServices 호출
|
||
if (!gatt.requestMtu(247)) {
|
||
gatt.discoverServices() // MTU 요청 실패 시 바로 진행
|
||
}
|
||
// 2026-07-07 fix: onConnectionStateChanged(true) 를 여기서 호출하면
|
||
// TX/RX characteristic 이 아직 null (onServicesDiscovered 미실행) 이라
|
||
// 상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시작해도
|
||
// sendRaw 에서 silent drop → RX 실종 → watchdog timeout → forced reconnect
|
||
// 좀비 세션의 원인. 콜백은 isServiceReady=true 시점 (onDescriptorWrite)
|
||
// 으로 이동.
|
||
}
|
||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||
logd { "Disconnected from ${gatt.device.address}" }
|
||
emitConnectionEvent(
|
||
com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED,
|
||
reason = "gatt state disconnected",
|
||
)
|
||
isConnecting.value = false
|
||
stopBatteryPolling()
|
||
stopRssiPolling()
|
||
rssi.value = null
|
||
commandQueue.clear("state-disconnected")
|
||
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
|
||
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
||
stopFirmwareRetry()
|
||
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
|
||
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
||
bluetoothGatt = null
|
||
txCharacteristic = null
|
||
rxCharacteristic = null
|
||
isConnected.value = false
|
||
isServiceReady.value = false
|
||
connectedDeviceName.value = ""
|
||
firmwareVersion.value = ""
|
||
hardwareVersion.value = ""
|
||
serialNumber.value = ""
|
||
batteryLevel.value = 0
|
||
|
||
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
||
|
||
onConnectionStateChanged?.invoke(false)
|
||
|
||
// 2026-08-24: 끊긴 이유와 무관하게 **항상** 로그에 남긴다.
|
||
// 이전에는 아래 if 안에서만 기록해서, 사용자가 직접 끊으면
|
||
// (isUserDisconnect=true) 아무 표시 없이 로그가 그냥 끊겼다.
|
||
debugLogger.disconnected(
|
||
if (isUserDisconnect) "user" else "unexpected",
|
||
status,
|
||
)
|
||
|
||
if (!isUserDisconnect && lastConnectedAddress != null) {
|
||
onUnexpectedDisconnect?.invoke()
|
||
scheduleAutoReconnect()
|
||
}
|
||
isUserDisconnect = false
|
||
gatt.close()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||
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")
|
||
}
|
||
"rcs:" -> {
|
||
// [rcs: 4B][freq][cycles][avg][delay_us][samples] 각 BE 2B + CRC 2B = 16B.
|
||
// 실패해도 이 응답은 온다 — freq 에 0xFFFF/0xFFFD 가 실린다(PiezoConfig.error).
|
||
if (data.size >= 14) {
|
||
fun be(i: Int) = ((data[i].toInt() and 0xFF) shl 8) or (data[i + 1].toInt() and 0xFF)
|
||
val cfg = PiezoConfig(be(4), be(6), be(8), be(10), be(12))
|
||
piezoConfigEcho.value = cfg
|
||
debugLogger.rx("rcs", data.size,
|
||
cfg.error?.let { "설정 실패: $it" }
|
||
?: "freq=${cfg.freq} cyc=${cfg.cycles} avg=${cfg.avg} " +
|
||
"delay=${cfg.delayUs} samples=${cfg.samples}")
|
||
} else {
|
||
debugLogger.rx("rcs", data.size, "too short")
|
||
}
|
||
}
|
||
"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)
|
||
}
|
||
}
|
||
}
|