e370d9c702
MTU 247 + CONN_PRIORITY HIGH 적용 후 한 사이클 응답이 ~280~420ms로 단축됨 (이전 ~1.2~1.4s 대비 약 4배). 응답 시간 대비 과도하게 보수적이었던 throttle/loop delay를 일괄 단축. 변경: - BleManager.canSendMaa() 시간 게이트 1500ms → 600ms (응답 평균 + 안전 마진 ~200ms) - BleManager.canSendMaa() FORCE 게이트 5000ms → 3000ms (응답 빨라진 만큼 deadlock 감지도 빠르게) - PiezoMonitor Auto loop delay 1500ms → 600ms - PlacementGuide continuous scan loop delay 1000ms → 600ms state-based gate(!isComplete)가 안전망 역할을 하므로 응답이 늦어지면 자동으로 차단되어 데이터 유실 위험은 그대로. 정상 경우엔 사이클당 ~0.6~0.7s로 실시간 모니터링 반응성 2배 향상. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1002 lines
41 KiB
Kotlin
1002 lines
41 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"
|
||
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 }
|
||
}
|
||
|
||
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 connectionTimer: Runnable? = null
|
||
private var isUserDisconnect = false
|
||
var lastConnectedAddress: String? = null
|
||
private set
|
||
private var reconnectAttempt = 0
|
||
private val reconnectIntervalMs: Long = 3000
|
||
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("")
|
||
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)
|
||
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
|
||
}
|
||
|
||
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
|
||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
||
|
||
connectionError.value = null
|
||
|
||
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}" }
|
||
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)
|
||
}
|
||
|
||
/** 저장된 마지막 기기에 자동 연결 시도 */
|
||
fun autoConnectLastDevice() {
|
||
if (isConnected.value) return
|
||
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||
val address = prefs.getString("last_device_address", null) ?: return
|
||
val name = prefs.getString("last_device_name", null) ?: return
|
||
debugLogger.info("AUTO_CONNECT attempting $name ($address)")
|
||
lastConnectedAddress = 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}" }
|
||
}
|
||
}
|
||
|
||
// 저장된 기기 정보 삭제
|
||
try {
|
||
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
|
||
} 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
|
||
}
|
||
|
||
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" -> "LED mode ${if (data.size > 4) data[4].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()
|
||
|
||
// ── 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
|
||
}
|
||
|
||
// ── 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() {
|
||
sendRaw(CRC16.buildCommandBE("msn", intArrayOf(0)))
|
||
}
|
||
|
||
fun sendDeviceInfoQuery() {
|
||
sendRaw(CRC16.buildCommandASCII("mid", " "))
|
||
}
|
||
|
||
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) 마지막 송신으로부터 600ms 미만이면 차단(펌웨어 burst 방어).
|
||
*
|
||
* FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 조합에서 한 사이클 응답이
|
||
* 약 280~420ms이므로, 600ms = 응답 평균 + 안전 마진 ~200ms.
|
||
*/
|
||
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 < 600) {
|
||
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
|
||
}
|
||
|
||
fun startBatteryPolling() {
|
||
sendBatteryQuery() // 연결 시 1회만
|
||
}
|
||
|
||
fun stopBatteryPolling() {
|
||
batteryTimer?.let { handler.removeCallbacks(it) }
|
||
batteryTimer = 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
|
||
}
|
||
|
||
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
|
||
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)
|
||
|
||
try {
|
||
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||
prefs.edit()
|
||
.putString("last_device_address", gatt.device.address)
|
||
.putString("last_device_name", connectedDeviceName.value)
|
||
.apply()
|
||
} 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}" }
|
||
stopBatteryPolling()
|
||
bluetoothGatt = null
|
||
txCharacteristic = null
|
||
rxCharacteristic = null
|
||
isConnected.value = false
|
||
isServiceReady.value = false
|
||
connectedDeviceName.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()
|
||
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()
|
||
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:" -> {
|
||
val samples = (data.size - 8) / 2
|
||
debugLogger.rx("reb", data.size, "${samples}samples")
|
||
piezoCollector.addPacket(data)
|
||
}
|
||
"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:" -> {
|
||
val info = if (data.size > 4) String(data, 4, data.size - 4, Charsets.US_ASCII).trim() else "?"
|
||
debugLogger.rx("rid", data.size, "device_info=$info")
|
||
debugLogger.info("DEVICE_INFO $info")
|
||
}
|
||
"rls:" -> {
|
||
val state = if (data.size >= 5) data[4].toInt() and 0xFF else -1
|
||
debugLogger.rx("rls", data.size, "led_state=$state")
|
||
}
|
||
"rxs:" -> {
|
||
val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?"
|
||
debugLogger.rx("rxs", data.size, "cmd_not_supported: $echoCmd")
|
||
}
|
||
"rbb:" -> {
|
||
debugLogger.rx("rbb", data.size, "full measurement header (battery+IMU+temp)")
|
||
onMbbHeaderReceived?.invoke(data)
|
||
piezoCollector.addPacket(data)
|
||
}
|
||
"rsp:" -> {
|
||
if (data.size >= 16) {
|
||
debugLogger.rx("rsp", data.size, "IMU data")
|
||
}
|
||
onImuReceived?.invoke(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" }
|
||
}
|
||
}
|
||
}
|
||
}
|