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 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" @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()) private var scanTimer: Runnable? = null private var batteryTimer: Runnable? = null private var connectionTimer: Runnable? = null private var isUserDisconnect = false val discoveredDevices = mutableStateListOf() 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(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) { Log.e(TAG, "BluetoothLeScanner is null") isScanning.value = false return } val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() try { bluetoothLeScanner?.startScan(null, settings, scanCallback) Log.d(TAG, "BLE scan started") } catch (e: Exception) { Log.e(TAG, "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) { Log.w(TAG, "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)) Log.d(TAG, "Discovered: $name (${device.address}) rssi=${result.rssi}") } } } override fun onScanFailed(errorCode: Int) { Log.e(TAG, "Scan failed with error code: $errorCode") handler.post { isScanning.value = false connectionError.value = "Scan failed (error $errorCode)" } } } // Connection fun connect(bleDevice: BleDevice) { stopScan() Log.d(TAG, "Connecting to ${bleDevice.name} (${bleDevice.address})") // Clean up any existing GATT connection bluetoothGatt?.let { gatt -> Log.d(TAG, "Closing existing GATT connection") try { gatt.disconnect() gatt.close() } catch (e: Exception) { Log.w(TAG, "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) Log.d(TAG, "connectGatt called, gatt=${bluetoothGatt != null}") } catch (e: Exception) { Log.e(TAG, "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) { Log.w(TAG, "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 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 ?: return val gatt = bluetoothGatt ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) } else { @Suppress("DEPRECATION") characteristic.value = data characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE @Suppress("DEPRECATION") gatt.writeCharacteristic(characteristic) } } // Piezo Commands fun sendPiezoPowerOn(freqOption: Int, cycles: Int) { 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))) } // NIRS Commands fun sendNirsPowerOn() { sendRaw(CRC16.buildCommand("mta", intArrayOf(1))) } 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) } handler.postDelayed({ sendBatteryQuery() }, 1000) val poll = object : Runnable { override fun run() { sendBatteryQuery() handler.postDelayed(this, 30000) } } batteryTimer = poll handler.postDelayed(poll, 31000) } private fun stopBatteryPolling() { batteryTimer?.let { handler.removeCallbacks(it) } batteryTimer = null } private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { Log.d(TAG, "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) { // Connection failed with error Log.e(TAG, "GATT connection failed with status: $status") bluetoothGatt = null txCharacteristic = null rxCharacteristic = null isConnected.value = false isServiceReady.value = false isReconnecting.value = false connectionError.value = "Connection failed (status $status). Try again." val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address } if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false) gatt.close() return@post } when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.d(TAG, "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" } // Save last connected 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() startBatteryPolling() onConnectionStateChanged?.invoke(true) } BluetoothProfile.STATE_DISCONNECTED -> { Log.d(TAG, "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) { onUnexpectedDisconnect?.invoke() } isUserDisconnect = false gatt.close() } } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { Log.d(TAG, "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) { Log.e(TAG, "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) Log.d(TAG, "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 { Log.w(TAG, "CCCD descriptor not found for RX characteristic") // No CCCD but still mark ready if characteristics exist handler.post { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true Log.d(TAG, "Service ready (no CCCD)") } } } } ?: run { // No RX characteristic, mark ready if TX exists handler.post { if (txCharacteristic != null) { isServiceReady.value = true Log.d(TAG, "Service ready (no RX)") } } } } override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Log.d(TAG, "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 Log.d(TAG, "Service ready! (CCCD written)") } } else { Log.e(TAG, "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) } } } } private fun processReceivedData(data: ByteArray) { val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII) Log.d(TAG, "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) "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) } } } } }