d3006d91dc
watchdog이 실제로 돌고 있는지, 어디서 멈추는지 진단용. 매 5초: WATCHDOG_CHECK silence=Xms connected=true/false interrupt 시: WATCHDOG_THREAD interrupted flag 종료 시: WATCHDOG_THREAD stopped Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
754 lines
30 KiB
Kotlin
754 lines
30 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
|
|
private var bluetoothGatt: BluetoothGatt? = null
|
|
private var txCharacteristic: BluetoothGattCharacteristic? = null
|
|
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 maxReconnectAttempts = 5
|
|
private val reconnectDelays = longArrayOf(3000, 5000, 10000, 10000, 10000)
|
|
private var reconnectTimer: Runnable? = null
|
|
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 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
|
|
|
|
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()
|
|
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 disconnect() {
|
|
isUserDisconnect = true
|
|
cancelAutoReconnect()
|
|
stopWatchdog()
|
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
|
connectionTimer = null
|
|
bluetoothGatt?.disconnect()
|
|
}
|
|
|
|
// Reconnection
|
|
fun reconnectToLastDevice(lastDeviceAddress: String?) {
|
|
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
|
|
onReconnectionFailed?.invoke()
|
|
return
|
|
}
|
|
|
|
// 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, 4, Charsets.US_ASCII).trimEnd('?') else "?"
|
|
logd { "sendRaw: $cmdPreview (${data.size} bytes)" }
|
|
debugLogger.tx("$cmdPreview (${data.size}B)", 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()
|
|
|
|
fun sendPiezoPowerOn(freqOption: Int = 2, cycles: Int = 5) {
|
|
sendRaw(CRC16.buildCommand("mpa", intArrayOf(freqOption, cycles)))
|
|
}
|
|
|
|
fun sendPiezoStop() {
|
|
sendRaw(CRC16.buildCommand("mps"))
|
|
}
|
|
|
|
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
|
|
sendRaw(CRC16.buildCommand("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
|
|
}
|
|
|
|
/** 6-channel measurement (maa) */
|
|
fun sendAllChannels(mode: Int = 0) {
|
|
piezoCollector.startMultiChannel(6)
|
|
sendRaw(CRC16.buildCommand("maa", intArrayOf(mode)))
|
|
}
|
|
|
|
// NIRS Commands
|
|
fun sendNirsPowerOn() {
|
|
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
|
|
}
|
|
|
|
fun sendNirsPowerOff() {
|
|
sendRaw(CRC16.buildCommand("mta", intArrayOf(0)))
|
|
}
|
|
|
|
fun sendMqqQuery() {
|
|
sendRaw(CRC16.buildCommand("mqq"))
|
|
}
|
|
|
|
fun sendMagQuery() {
|
|
sendRaw(CRC16.buildCommand("mag"))
|
|
}
|
|
|
|
fun sendMcjQuery() {
|
|
sendRaw(CRC16.buildCommand("mcj"))
|
|
}
|
|
|
|
fun sendBatteryQuery() {
|
|
sendRaw(CRC16.buildCommand("msn", intArrayOf(0)))
|
|
}
|
|
|
|
fun startBatteryPolling() {
|
|
batteryTimer?.let { handler.removeCallbacks(it) }
|
|
sendBatteryQuery()
|
|
bluetoothGatt?.readRemoteRssi()
|
|
val poll = object : Runnable {
|
|
override fun run() {
|
|
sendBatteryQuery()
|
|
bluetoothGatt?.readRemoteRssi()
|
|
handler.postDelayed(this, 5000)
|
|
}
|
|
}
|
|
batteryTimer = poll
|
|
handler.postDelayed(poll, 5000)
|
|
}
|
|
|
|
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({
|
|
debugLogger.info("WATCHDOG_THREAD running")
|
|
while (watchdogRunning) {
|
|
try { Thread.sleep(5000) } catch (e: InterruptedException) {
|
|
debugLogger.warn("WATCHDOG_THREAD interrupted")
|
|
break
|
|
}
|
|
if (!watchdogRunning) {
|
|
debugLogger.info("WATCHDOG_THREAD stopped (flag)")
|
|
break
|
|
}
|
|
|
|
val silenceMs = System.currentTimeMillis() - lastRxTimestamp
|
|
debugLogger.info("WATCHDOG_CHECK silence=${silenceMs}ms connected=${isConnected.value}")
|
|
|
|
if (!isConnected.value) continue
|
|
|
|
if (silenceMs > watchdogTimeoutMs) {
|
|
// 직접 로그 기록 (handler 블로킹 무관)
|
|
debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect")
|
|
|
|
// GATT close는 어느 스레드에서든 호출 가능
|
|
watchdogRunning = false
|
|
try {
|
|
bluetoothGatt?.disconnect()
|
|
bluetoothGatt?.close()
|
|
} catch (_: Exception) {}
|
|
bluetoothGatt = null
|
|
txCharacteristic = null
|
|
rxCharacteristic = null
|
|
|
|
// UI 업데이트 + reconnect는 메인 스레드
|
|
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() {
|
|
if (reconnectAttempt >= maxReconnectAttempts) {
|
|
logw { "Max reconnect attempts ($maxReconnectAttempts) reached" }
|
|
debugLogger.reconnectFailed()
|
|
isReconnecting.value = false
|
|
onReconnectionFailed?.invoke()
|
|
return
|
|
}
|
|
|
|
val delay = reconnectDelays[reconnectAttempt.coerceAtMost(reconnectDelays.size - 1)]
|
|
logd { "Auto-reconnect attempt ${reconnectAttempt + 1}/$maxReconnectAttempts in ${delay}ms" }
|
|
debugLogger.reconnecting(reconnectAttempt + 1, maxReconnectAttempts, delay)
|
|
isReconnecting.value = true
|
|
|
|
cancelReconnectTimer()
|
|
val timer = Runnable { attemptAutoReconnect() }
|
|
reconnectTimer = timer
|
|
handler.postDelayed(timer, delay)
|
|
}
|
|
|
|
private var reconnectScanCallback: ScanCallback? = null
|
|
|
|
private fun attemptAutoReconnect() {
|
|
val targetAddress = lastConnectedAddress ?: return
|
|
val targetName = debugLogger.deviceName
|
|
if (bluetoothAdapter?.isEnabled != true) {
|
|
onReconnectionFailed?.invoke()
|
|
isReconnecting.value = false
|
|
return
|
|
}
|
|
|
|
reconnectAttempt++
|
|
logd { "Auto-reconnect #$reconnectAttempt: scanning for $targetName ($targetAddress)" }
|
|
debugLogger.info("RECONNECT_SCAN #$reconnectAttempt scanning for $targetName")
|
|
|
|
bluetoothGatt?.let { gatt ->
|
|
try { 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) {
|
|
val name = try { result.device.name ?: "" } catch (_: Exception) { "" }
|
|
val address = result.device.address
|
|
|
|
if (address == targetAddress || (targetName.isNotEmpty() && name == targetName)) {
|
|
logd { "Reconnect scan found: $name ($address)" }
|
|
debugLogger.info("RECONNECT_FOUND $name ($address) RSSI=${result.rssi}")
|
|
|
|
try { scanner.stopScan(this) } catch (_: Exception) {}
|
|
reconnectScanCallback = null
|
|
|
|
// Connect directly (autoConnect=false for fast connection)
|
|
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 && reconnectAttempt < maxReconnectAttempts) {
|
|
debugLogger.warn("RECONNECT_SCAN_TIMEOUT #$reconnectAttempt — device not found")
|
|
scheduleAutoReconnect()
|
|
} else if (!isConnected.value) {
|
|
debugLogger.reconnectFailed()
|
|
isReconnecting.value = false
|
|
onReconnectionFailed?.invoke()
|
|
}
|
|
}, 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) {
|
|
loge { "GATT connection failed with status: $status" }
|
|
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 (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 from name
|
|
com.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
|
|
logd { "Device preset: ${com.example.medilightv2android.managers.PiezoHW.presetName} (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).apply()
|
|
} catch (_: Exception) {}
|
|
|
|
gatt.discoverServices()
|
|
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 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
|
|
startBatteryPolling()
|
|
startWatchdog()
|
|
logd { "Service ready! (CCCD written)" }
|
|
}
|
|
} else {
|
|
loge { "CCCD write failed with status: $status" }
|
|
connectionError.value = "Notification setup failed (status $status)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
|
if (characteristic.uuid == RX_CHAR_UUID) {
|
|
if (value.size < 4) return
|
|
handler.post { processReceivedData(value) }
|
|
}
|
|
}
|
|
|
|
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
|
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
|
if (characteristic.uuid == RX_CHAR_UUID) {
|
|
@Suppress("DEPRECATION")
|
|
val data = characteristic.value ?: return
|
|
if (data.size < 4) return
|
|
handler.post { 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)
|
|
debugLogger.rx(prefix.trimEnd(':', '!'), data.size)
|
|
logd { "Received: $prefix (${data.size} bytes)" }
|
|
when (prefix) {
|
|
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
|
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
|
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
|
|
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
|
|
"rpa:" -> onPiezoDataReceived?.invoke(data)
|
|
// Piezo echo responses → route to packet collector
|
|
"rer:" -> {
|
|
// VesiScan-Basic preliminary header — just acknowledge, reb: follows
|
|
logd { "rer: preliminary header (${data.size} bytes)" }
|
|
}
|
|
"reb:" -> piezoCollector.addPacket(data)
|
|
"red:" -> piezoCollector.addPacket(data)
|
|
"ree:", "raa:" -> piezoCollector.addPacket(data)
|
|
"rsn:" -> {
|
|
if (data.size >= 6) {
|
|
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
|
batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100)
|
|
}
|
|
}
|
|
else -> logw { "Unknown prefix: '$prefix' hex=${data.take(8).joinToString(" ") { "%02X".format(it) }}" }
|
|
}
|
|
}
|
|
}
|