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 reconnectIntervalMs: Long = 3000 private var reconnectTimer: Runnable? = null private var lastRxTimestamp: Long = 0 private var watchdogTimer: Runnable? = null private val watchdogTimeoutMs: Long = 15000 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 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() } fun disconnectAndUnbond() { isUserDisconnect = true cancelAutoReconnect() stopWatchdog() stopBatteryPolling() 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) { piezoCollector.startMultiChannel(6) sendRaw(CRC16.buildCommandASCII("maa", " ")) } // ── 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) */ fun sendChannelsOnly() { piezoCollector.startMultiChannel(6) sendRaw(CRC16.buildCommandASCII("maa", " ")) } 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) {} 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() sendDeviceInfoQuery() 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 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 (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 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) 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" } } } } }