6a06452d7e
용도: IMU FSR / 채널 누락 / packet 구조 진단을 위해 BLE notification 의 raw
bytes 를 가공 없이 그대로 파일로 저장.
신규 RawBleLogger.kt:
- startCapture() / stopCapture() / isCapturing
- 파일: Downloads/VesiScan_RawBLE/yyyy-MM-dd_HHmmss_raw.log
- 포맷: "HH:mm:ss.SSS dir=RX prefix=rim len=188 hex=72 69 6D 3A ..."
- 매 notification 마다 한 줄
- 파일 시작/끝에 헤더/푸터 (start, stop time, packet count)
BleManager.processReceivedData:
- 진입 직후 RawBleLogger.write(data) 호출
- capturing 아니면 즉시 return (no overhead)
PiezoMonitoringView 상단 status bar:
- posture 아이콘 옆에 토글 IconButton
- 활성: 빨간 RadioButtonChecked, 비활성: 회색 FiberManualRecord
- 토글 시 Toast 로 파일 경로 / packet count 안내
사용:
1) PiezoMonitoring 진입 → 빨간 ● 버튼 탭 → "Raw BLE capture started"
2) BLE 측정 진행 (alignment / live / single scan)
3) ● 다시 탭 → "N packets saved to YYYY-MM-DD_HHmmss_raw.log"
4) adb pull /sdcard/Download/VesiScan_RawBLE/ 로 파일 회수
5) 텍스트 에디터로 hex 분석
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1235 lines
52 KiB
Kotlin
1235 lines
52 KiB
Kotlin
package com.example.medilightv2android.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.example.medilightv2android.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.example.medilightv2android.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 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초). */
|
||
private val MAX_RECONNECT_ATTEMPTS = 10
|
||
private var reconnectTimer: Runnable? = null
|
||
@Volatile private var lastRxTimestamp: Long = 0
|
||
private var watchdogTimer: Runnable? = null
|
||
private val watchdogTimeoutMs: Long = 15000
|
||
|
||
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()
|
||
}
|
||
|
||
/** 펌웨어 버전이 MIN_FIRMWARE_BUILD 미만이면 true. 모르거나(아직 미수신) 형식 안 맞으면 false. */
|
||
val isFirmwareOutdated: Boolean
|
||
get() {
|
||
val n = parseFirmwareBuild(firmwareVersion.value) ?: return false
|
||
return n < MIN_FIRMWARE_BUILD
|
||
}
|
||
|
||
val batteryLevel = mutableStateOf(0)
|
||
val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
|
||
val isServiceReady = mutableStateOf(false)
|
||
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)
|
||
|
||
// Callbacks
|
||
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
|
||
var onNirsPowerOnReceived: (() -> Unit)? = null
|
||
var onNirsSensorActivated: (() -> Unit)? = null
|
||
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
|
||
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
|
||
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
|
||
var onUnexpectedDisconnect: (() -> Unit)? = null
|
||
var onReconnectionFailed: (() -> Unit)? = null
|
||
var onImuReceived: ((ByteArray) -> Unit)? = null
|
||
var onMbbHeaderReceived: ((ByteArray) -> Unit)? = null
|
||
|
||
val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true
|
||
|
||
// Scanning
|
||
fun startScan() {
|
||
if (bluetoothAdapter?.isEnabled != true) {
|
||
bluetoothEnabled.value = false
|
||
return
|
||
}
|
||
bluetoothEnabled.value = true
|
||
discoveredDevices.clear()
|
||
isScanning.value = true
|
||
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
|
||
|
||
if (bluetoothLeScanner == null) {
|
||
loge { "BluetoothLeScanner is null" }
|
||
isScanning.value = false
|
||
return
|
||
}
|
||
|
||
val settings = ScanSettings.Builder()
|
||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||
.build()
|
||
|
||
try {
|
||
bluetoothLeScanner?.startScan(null, settings, scanCallback)
|
||
logd { "BLE scan started" }
|
||
} catch (e: Exception) {
|
||
loge { "Failed to start scan: ${e.message}" }
|
||
isScanning.value = false
|
||
return
|
||
}
|
||
|
||
scanTimer?.let { handler.removeCallbacks(it) }
|
||
val timeout = Runnable { stopScan() }
|
||
scanTimer = timeout
|
||
handler.postDelayed(timeout, 15000)
|
||
}
|
||
|
||
fun stopScan() {
|
||
try {
|
||
bluetoothLeScanner?.stopScan(scanCallback)
|
||
} catch (e: Exception) {
|
||
logw { "Failed to stop scan: ${e.message}" }
|
||
}
|
||
isScanning.value = false
|
||
scanTimer?.let { handler.removeCallbacks(it) }
|
||
scanTimer = null
|
||
}
|
||
|
||
private val scanCallback = object : ScanCallback() {
|
||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||
val device = result.device
|
||
val name = try { device.name } catch (_: Exception) { null } ?: return
|
||
if (name.isEmpty()) return
|
||
|
||
handler.post {
|
||
val existing = discoveredDevices.indexOfFirst { it.address == device.address }
|
||
if (existing >= 0) {
|
||
discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi)
|
||
} else {
|
||
discoveredDevices.add(BleDevice(device, result.rssi))
|
||
logd { "Discovered: $name (${device.address}) rssi=${result.rssi}" }
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun onScanFailed(errorCode: Int) {
|
||
loge { "Scan failed with error code: $errorCode" }
|
||
handler.post {
|
||
isScanning.value = false
|
||
connectionError.value = "Scan failed (error $errorCode)"
|
||
}
|
||
}
|
||
}
|
||
|
||
// Connection
|
||
fun connect(bleDevice: BleDevice) {
|
||
stopScan()
|
||
cancelAutoReconnect()
|
||
reconnectScanCallback?.let { cb ->
|
||
try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
|
||
reconnectScanCallback = null
|
||
}
|
||
reconnectConnecting = false
|
||
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
|
||
|
||
// Clean up any existing GATT connection
|
||
bluetoothGatt?.let { gatt ->
|
||
logd { "Closing existing GATT connection" }
|
||
try {
|
||
gatt.disconnect()
|
||
gatt.close()
|
||
} catch (e: Exception) {
|
||
logw { "Error closing old GATT: ${e.message}" }
|
||
}
|
||
bluetoothGatt = null
|
||
txCharacteristic = null
|
||
rxCharacteristic = null
|
||
}
|
||
|
||
// 이전 디바이스의 state 가 남아있는 걸 방지 — 매 connect 마다 fresh.
|
||
// 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확.
|
||
// (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응)
|
||
firmwareVersion.value = ""
|
||
hardwareVersion.value = ""
|
||
serialNumber.value = ""
|
||
batteryLevel.value = 0
|
||
connectedDeviceName.value = ""
|
||
isServiceReady.value = false
|
||
|
||
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
|
||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
||
|
||
connectionError.value = null
|
||
isConnecting.value = true
|
||
|
||
try {
|
||
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
|
||
} catch (e: Exception) {
|
||
loge { "connectGatt failed: ${e.message}" }
|
||
isConnecting.value = false
|
||
handler.post {
|
||
connectionError.value = "Connection failed: ${e.message}"
|
||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Connection timeout
|
||
connectionTimer?.let { handler.removeCallbacks(it) }
|
||
val timeout = Runnable {
|
||
if (!isConnected.value) {
|
||
logw { "Connection timeout" }
|
||
bluetoothGatt?.let { gatt ->
|
||
try {
|
||
gatt.disconnect()
|
||
gatt.close()
|
||
} catch (_: Exception) {}
|
||
}
|
||
bluetoothGatt = null
|
||
connectionError.value = "Connection timed out. Try again."
|
||
if (idx >= 0 && idx < discoveredDevices.size) {
|
||
discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
||
}
|
||
}
|
||
}
|
||
connectionTimer = timeout
|
||
handler.postDelayed(timeout, 10000)
|
||
}
|
||
|
||
/**
|
||
* 특정 주소의 known device 페어링 해제 — 현재 연결된 기기가 아니어도 사용 가능.
|
||
* 연결된 상태라면 일반 disconnectAndUnbond() 를 사용.
|
||
*/
|
||
fun unbondAndRemoveAddress(address: String) {
|
||
// 현재 연결된 기기를 해제하려는 경우
|
||
if (bluetoothGatt?.device?.address == address) {
|
||
disconnectAndUnbond()
|
||
return
|
||
}
|
||
// 연결 안 된 known device — OS bond 만 끊고 list 에서 제거
|
||
val adapter = bluetoothAdapter ?: return
|
||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull()
|
||
device?.let { dev ->
|
||
try {
|
||
val method = dev.javaClass.getMethod("removeBond")
|
||
method.invoke(dev)
|
||
logd { "removeBond called for ${dev.address}" }
|
||
} catch (e: Exception) {
|
||
logw { "removeBond failed: ${e.message}" }
|
||
}
|
||
}
|
||
com.example.medilightv2android.services.KnownDeviceStore.remove(context, address)
|
||
debugLogger.info("DEVICE_UNREGISTERED $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.example.medilightv2android.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
|
||
bluetoothGatt?.disconnect()
|
||
com.example.medilightv2android.services.BleForegroundService.stop(context)
|
||
}
|
||
|
||
fun disconnectAndUnbond() {
|
||
isUserDisconnect = true
|
||
cancelAutoReconnect()
|
||
stopWatchdog()
|
||
stopBatteryPolling()
|
||
com.example.medilightv2android.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.example.medilightv2android.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
|
||
fun sendRaw(data: ByteArray) {
|
||
val characteristic = txCharacteristic
|
||
val gatt = bluetoothGatt
|
||
if (characteristic == null) {
|
||
loge { "sendRaw: txCharacteristic is null!" }
|
||
return
|
||
}
|
||
if (gatt == null) {
|
||
loge { "sendRaw: bluetoothGatt is null!" }
|
||
return
|
||
}
|
||
val cmdPreview = if (data.size >= 4) String(data, 0, 3, Charsets.US_ASCII) else "?"
|
||
val cmdDetail = when (cmdPreview) {
|
||
"maa" -> "6ch measure"
|
||
"mpa" -> "piezo power ON"
|
||
"mps" -> "piezo stop"
|
||
"mec" -> "single burst"
|
||
"msn" -> "battery query"
|
||
"mta" -> if (data.size > 4 && data[4].toInt() == 1) "NIRS power ON" else "NIRS power OFF"
|
||
"mqq" -> "NIRS activate"
|
||
"mag" -> "NIRS gain query"
|
||
"mcj" -> "NIRS MCJ query"
|
||
"mid" -> "device info query"
|
||
// mls 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low
|
||
"mls" -> "LED mode ${
|
||
if (data.size > 5) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
||
else "?"
|
||
}"
|
||
"msp" -> "IMU query"
|
||
"mbb" -> "full measurement (battery+IMU+temp+6ch)"
|
||
else -> ""
|
||
}
|
||
logd { "sendRaw: $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 { "sendRaw result: $result" }
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
characteristic.value = data
|
||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||
@Suppress("DEPRECATION")
|
||
val ok = gatt.writeCharacteristic(characteristic)
|
||
logd { "sendRaw success: $ok" }
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
sendRaw(CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
|
||
}
|
||
|
||
fun sendAllChannels(mode: Int = 0): Boolean {
|
||
if (!canSendMaa("sendAllChannels")) return false
|
||
lastMaaSentMs = System.currentTimeMillis()
|
||
piezoCollector.startMultiChannel(6)
|
||
sendRaw(CRC16.buildCommandASCII("maa", " "))
|
||
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()
|
||
sendRaw(CRC16.buildCommandASCII("mtb", " "))
|
||
return true
|
||
}
|
||
|
||
// ── NIRS Commands ──
|
||
|
||
fun sendNirsPowerOn() {
|
||
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(1)))
|
||
}
|
||
|
||
fun sendNirsPowerOff() {
|
||
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(0)))
|
||
}
|
||
|
||
fun sendMqqQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mqq", " "))
|
||
}
|
||
|
||
fun sendMagQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mag", " "))
|
||
}
|
||
|
||
fun sendMcjQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mcj", " "))
|
||
}
|
||
|
||
// ── Common Commands ──
|
||
|
||
fun sendBatteryQuery() {
|
||
// mid?/mtb?/maa? 등 다른 단순 query와 동일한 ASCII " " 형식 — BE [0]은
|
||
// 펌웨어가 응답 안 함이 확인됨 (rsn: 자체가 안 옴, 2026-06-08).
|
||
sendRaw(CRC16.buildCommandASCII("msn", " "))
|
||
}
|
||
|
||
fun sendDeviceInfoQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mid", " "))
|
||
}
|
||
|
||
/** mfv? — FW Version only. mid? 응답 안 오는 펌웨어용 fallback. */
|
||
fun sendFirmwareVersionQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mfv", " "))
|
||
}
|
||
|
||
fun sendLedMode(state: Int) {
|
||
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
|
||
}
|
||
|
||
fun sendImuQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("msp", " "))
|
||
}
|
||
|
||
/** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */
|
||
fun sendFullMeasurement() {
|
||
piezoCollector.startMultiChannel(6)
|
||
sendRaw(CRC16.buildCommandASCII("mbb", " "))
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
sendRaw(CRC16.buildCommandASCII("maa", " "))
|
||
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)" }
|
||
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
|
||
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
|
||
}
|
||
|
||
// ── 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
|
||
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs) {
|
||
handler.post { sendImuQuery() }
|
||
}
|
||
|
||
if (silenceMs > watchdogTimeoutMs) {
|
||
debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect")
|
||
|
||
watchdogRunning = false
|
||
try {
|
||
bluetoothGatt?.disconnect()
|
||
bluetoothGatt?.close()
|
||
} catch (_: Exception) {}
|
||
bluetoothGatt = null
|
||
txCharacteristic = null
|
||
rxCharacteristic = null
|
||
|
||
handler.post {
|
||
stopBatteryPolling()
|
||
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
|
||
}
|
||
|
||
private fun forceDisconnectAndReconnect() {
|
||
stopBatteryPolling()
|
||
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
|
||
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()
|
||
stopWatchdog()
|
||
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
|
||
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
|
||
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.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
|
||
piezoCollector.forceBigEndian = connectedDeviceName.value.startsWith("VBT")
|
||
logd { "Device preset: ${com.example.medilightv2android.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)
|
||
|
||
// Clinical 흐름에서만 KnownDeviceStore 영속화. 일반 사용자는
|
||
// 매 세션 fresh scan + pair — 저장 0개.
|
||
if (com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow) {
|
||
try {
|
||
com.example.medilightv2android.services.KnownDeviceStore.add(
|
||
context, gatt.device.address, connectedDeviceName.value
|
||
)
|
||
} catch (_: Exception) {}
|
||
}
|
||
|
||
// MTU 247 요청 (reb: 208B 패킷 드롭 방지)
|
||
// onMtuChanged 콜백에서 discoverServices 호출
|
||
if (!gatt.requestMtu(247)) {
|
||
gatt.discoverServices() // MTU 요청 실패 시 바로 진행
|
||
}
|
||
onConnectionStateChanged?.invoke(true)
|
||
}
|
||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||
logd { "Disconnected from ${gatt.device.address}" }
|
||
isConnecting.value = false
|
||
stopBatteryPolling()
|
||
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
|
||
startBatteryPolling()
|
||
startWatchdog()
|
||
logd { "Service ready (no CCCD)" }
|
||
}
|
||
}
|
||
}
|
||
} ?: run {
|
||
handler.post {
|
||
if (txCharacteristic != null) {
|
||
isServiceReady.value = true
|
||
startBatteryPolling()
|
||
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
|
||
// 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.example.medilightv2android.services.AdcCsvLogger.newSession()
|
||
com.example.medilightv2android.services.BleForegroundService.start(context)
|
||
startBatteryPolling()
|
||
startWatchdog()
|
||
sendDeviceInfoQuery()
|
||
// 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성)
|
||
handler.postDelayed({
|
||
if (firmwareVersion.value.isEmpty() && isConnected.value) {
|
||
debugLogger.info("FW fallback: sending mfv?")
|
||
sendFirmwareVersionQuery()
|
||
}
|
||
}, 4_000)
|
||
logd { "Service ready! (CCCD written)" }
|
||
}
|
||
} else {
|
||
loge { "CCCD write failed with status: $status — retrying in 500ms" }
|
||
// GATT 133 등 일시적 오류 → 500ms 후 재시도
|
||
handler.postDelayed({
|
||
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)"
|
||
}
|
||
}, 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) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun processReceivedData(data: ByteArray) {
|
||
lastRxTimestamp = System.currentTimeMillis()
|
||
// Raw BLE capture (디버그 — 사용자가 토글로 enable 했을 때만 writes)
|
||
com.example.medilightv2android.services.RawBleLogger.write(data)
|
||
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||
logd { "Received: $prefix (${data.size} bytes)" }
|
||
when (prefix) {
|
||
"rta:", "rta!" -> {
|
||
debugLogger.rx("rta", data.size, "piezo power ON")
|
||
onNirsPowerOnReceived?.invoke()
|
||
}
|
||
"rsh:", "rsh!", "rqq:", "rqq!" -> {
|
||
debugLogger.rx(prefix.take(3), data.size, "sensor activated")
|
||
onNirsSensorActivated?.invoke()
|
||
}
|
||
"rag:", "rag!" -> {
|
||
debugLogger.rx("rag", data.size, "gain calibration (${(data.size - 6) / 2} values)")
|
||
onNirsMagReceived?.invoke(data)
|
||
}
|
||
"rcj:", "rcj!" -> {
|
||
debugLogger.rx("rcj", data.size, "MCJ packet")
|
||
onNirsMcjReceived?.invoke(data)
|
||
}
|
||
"rpa:" -> {
|
||
debugLogger.rx("rpa", data.size, "piezo ack")
|
||
onPiezoDataReceived?.invoke(data)
|
||
}
|
||
"rer:" -> {
|
||
debugLogger.rx("rer", data.size, "preliminary header")
|
||
}
|
||
"reb:" -> {
|
||
// 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10
|
||
val samples = ((data.size - 10) / 2).coerceAtLeast(0)
|
||
val chSession = if (data.size > 4) data[4].toInt() and 0xFF else -1
|
||
val chNum = if (data.size > 5) data[5].toInt() and 0xFF else -1
|
||
debugLogger.rx("reb", data.size, "session=$chSession ch=$chNum ${samples}samples")
|
||
piezoCollector.addPacket(data)
|
||
lastBleRxAt.value = System.currentTimeMillis() // 기기 alive 마크
|
||
}
|
||
"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)
|
||
}
|
||
"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}%)")
|
||
}
|
||
}
|
||
"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)
|
||
}
|
||
"rxs:" -> {
|
||
val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?"
|
||
android.util.Log.w("ImuDbg", "rxs cmd_not_supported: $echoCmd")
|
||
debugLogger.rx("rxs", data.size, "cmd_not_supported: $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)
|
||
}
|
||
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" }
|
||
}
|
||
}
|
||
}
|
||
}
|