BLEManager.kt
package io.github.tritbool.euc.ble.core
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback as AndroidScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
import io.github.tritbool.euc.ble.exceptions.BLEException
import io.github.tritbool.euc.ble.integration.BleBackendEvent
import io.github.tritbool.euc.ble.integration.BleBackendListener
import io.github.tritbool.euc.ble.models.EUCData
import io.github.tritbool.euc.ble.models.EUCDevice
import io.github.tritbool.euc.ble.protocols.CommandSupport
import io.github.tritbool.euc.ble.protocols.CommandType
import io.github.tritbool.euc.ble.protocols.EUCProtocol
import io.github.tritbool.euc.ble.protocols.EucFingerprintDatabase
import io.github.tritbool.euc.ble.protocols.GattSignature
import io.github.tritbool.euc.ble.protocols.InMotionProtocol
import io.github.tritbool.euc.ble.protocols.ProtocolQuerySpec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
/**
* Main BLE manager for Electric Unicycles.
*
* This class is responsible for BLE scanning, connection lifecycle management,
* protocol selection, command transmission, and decoded data delivery.
*
* Threading model:
* - Internal asynchronous work is executed on a background coroutine scope based on
* `Dispatchers.IO + SupervisorJob()`.
* - Public callbacks exposed by this manager (`ConnectionCallback`, `DataCallback`,
* and `ErrorCallback`) are not guaranteed to be invoked on the Android main thread.
* - Callers must explicitly switch to `Dispatchers.Main`, `runOnUiThread`, or an
* equivalent UI mechanism before touching Android views.
*
* Rationale:
* - BLE operations, retries, polling, and response timeouts are background work and
* should not run on the main thread.
* - Keeping callback dispatching explicit avoids hiding threading behavior from the
* library consumer and keeps the manager usable from non-UI contexts as well.
*/
class BLEManager internal constructor(
private val context: Context, private val logger: Logger = AndroidLogger(),
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) : BluetoothGattCallback() {
companion object {
private const val TAG = "BLEManager"
private const val MIN_QUERY_ATTEMPTS = 1
private const val MIN_QUERY_TIMEOUT_MS = 200L
val ENABLE_NOTIFICATION_VALUE_COMPAT = byteArrayOf(0x01, 0x00)
val ENABLE_INDICATION_VALUE_COMPAT = byteArrayOf(0x02, 0x00)
val DISABLE_NOTIFICATION_VALUE_COMPAT = byteArrayOf(0x00, 0x00)
}
// Configuration
private var scanTimeout: Long = BLEConstants.DEFAULT_SCAN_TIMEOUT_MS
private var connectionTimeout: Long = BLEConstants.DEFAULT_CONNECTION_TIMEOUT_MS
private var autoReconnect: Boolean = true
private var maxRetries: Int = 3
// State management
private var connectionState: BLEConstants.ConnectionState =
BLEConstants.ConnectionState.DISCONNECTED
private var currentDevice: EUCDevice? = null
private var bluetoothGatt: BluetoothGatt? = null
private var bluetoothAdapter: BluetoothAdapter? = null
// Reconnection state fields
private var reconnectRetryCount: Int = 0
private var reconnectJob: Job? = null
private var manualDisconnect: Boolean = false
private val reconnectBaseDelayMs: Long = BLEConstants.RECONNECT_BASE_DELAY_MS
private val maxReconnectDelayMs: Long = BLEConstants.MAX_RECONNECT_DELAY_MS
// Protocol management
@VisibleForTesting(otherwise = PRIVATE)
internal val protocols: MutableList<EUCProtocol> = mutableListOf()
@VisibleForTesting(otherwise = PRIVATE)
internal var currentProtocol: EUCProtocol? = null
private var protocolSelectionMode: ProtocolSelectionMode = ProtocolSelectionMode.AUTO
private var forcedProtocol: EUCProtocol? = null
private var awaitingManualProtocolSelection: Boolean = false
private var queryOrchestrationJob: Job? = null
private var dataFlowCollectorJob: Job? = null
private var writeFlowCollectorJob: Job? = null
private val pendingQueries: MutableMap<String, PendingQueryState> = ConcurrentHashMap()
// Callbacks
private var platformScanCallback: AndroidScanCallback? = null
private var connectionCallback: ConnectionCallback? = null
private var dataCallback: DataCallback? = null
private var errorCallback: ErrorCallback? = null
// Raw frame capture: every raw BLE characteristic notification is emitted here
private val _rawFrameFlow = MutableSharedFlow<ByteArray>(
extraBufferCapacity = BLEConstants.DEFAULT_FLOW_BUFFER_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
/**
* Flow emitting every raw BLE characteristic notification received from the
* connected device as a defensive copy of the original byte array.
*
* This flow can be collected to implement raw frame logging, packet inspection,
* or custom decoding pipelines independent from the built-in protocol decoder.
*
* Threading notes:
* - Emissions originate from BLE callback handling and therefore must be treated
* as background events.
* - Collectors are responsible for choosing the appropriate collection context.
*
* Example:
* ```kotlin
* bleManager.rawFrameFlow.collect { bytes ->
* outputStream.write(bytes)
* }
* ```
*/
val rawFrameFlow: SharedFlow<ByteArray> = _rawFrameFlow.asSharedFlow()
private val _queryTraceFlow = MutableSharedFlow<QueryTraceEvent>(
extraBufferCapacity = BLEConstants.DEFAULT_FLOW_BUFFER_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val queryTraceFlow: SharedFlow<QueryTraceEvent> = _queryTraceFlow.asSharedFlow()
// Coroutine management
private var scanJob: Job? = null
private var connectionJob: Job? = null
private var scanTimeoutJob: Job? = null
private var connectionTimeoutJob: Job? = null
private data class PendingQueryState(
val protocolName: String, val query: ProtocolQuerySpec, val attempt: Int, val sentAtMs: Long
)
// Device cache
private val discoveredDevices = ConcurrentHashMap<String, EUCDevice>()
/**
* Initialize the BLE Manager
*/
fun initialize() {
logger.info(TAG, "Initializing BLE Manager")
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
logger.error(TAG, "Bluetooth not supported on this device")
errorCallback?.onError(BLEException("Bluetooth not supported on this device"))
} else {
logger.info(TAG, "Bluetooth adapter initialized successfully")
}
}
/**
* Register a protocol for device detection and data processing
*/
fun registerProtocol(protocol: EUCProtocol) {
protocols.add(protocol)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun setProtocolSelectionMode(mode: ProtocolSelectionMode) {
protocolSelectionMode =
if (mode == ProtocolSelectionMode.FORCED && forcedProtocol == null) {
errorCallback?.onError(BLEException("Cannot enable forced protocol mode without a forced protocol"))
ProtocolSelectionMode.AUTO
} else {
mode
}
if (protocolSelectionMode != ProtocolSelectionMode.AUTO_WITH_MANUAL_FALLBACK) {
awaitingManualProtocolSelection = false
}
if (protocolSelectionMode == ProtocolSelectionMode.FORCED) {
maybeActivateForcedProtocol()
} else if (protocolSelectionMode == ProtocolSelectionMode.AUTO_WITH_MANUAL_FALLBACK && currentProtocol == null) {
notifyProtocolSelectionRequired()
}
}
fun getProtocolSelectionMode(): ProtocolSelectionMode = protocolSelectionMode
/**
* Returns all registered protocols so the caller can inspect and choose one if needed.
*/
fun getRegisteredProtocols(): List<EUCProtocol> = protocols.toList()
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun selectProtocol(protocol: EUCProtocol): Boolean {
if (currentDevice == null) {
errorCallback?.onError(BLEException("No connected device available for manual protocol selection"))
return false
}
if (!protocols.contains(protocol)) {
errorCallback?.onError(BLEException("Protocol '${protocolIdentifier(protocol)}' is not registered"))
return false
}
awaitingManualProtocolSelection = false
return activateProtocolIfReady(
protocol,
ProtocolSelectionReason.MANUAL_FALLBACK,
"manual fallback"
)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun forceProtocol(protocol: EUCProtocol): Boolean {
if (!protocols.contains(protocol)) {
errorCallback?.onError(BLEException("Protocol '${protocolIdentifier(protocol)}' is not registered"))
return false
}
forcedProtocol = protocol
protocolSelectionMode = ProtocolSelectionMode.FORCED
awaitingManualProtocolSelection = false
return maybeActivateForcedProtocol(protocol) ?: true
}
fun clearForcedProtocol() {
forcedProtocol = null
if (protocolSelectionMode == ProtocolSelectionMode.FORCED) {
protocolSelectionMode = ProtocolSelectionMode.AUTO
}
}
/**
* Start scanning for EUC devices
*/
@RequiresApi(Build.VERSION_CODES.M)
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
fun startScan() {
logger.info(TAG, "Starting BLE scan")
if (connectionState != BLEConstants.ConnectionState.DISCONNECTED) {
logger.warn(TAG, "Cannot scan while connected")
errorCallback?.onError(BLEException("Cannot scan while connected"))
return
}
if (!isBluetoothEnabled()) {
logger.error(TAG, "Bluetooth is disabled")
errorCallback?.onError(BLEException("Bluetooth is disabled"))
return
}
discoveredDevices.clear()
connectionState = BLEConstants.ConnectionState.DISCONNECTED
scanJob = coroutineScope.launch {
startBleScan()
}
scanTimeoutJob = coroutineScope.launch {
delay(scanTimeout.milliseconds)
if (connectionState == BLEConstants.ConnectionState.DISCONNECTED
&& platformScanCallback != null
) {
logger.info(TAG, "Scan timeout reached")
stopScan()
}
}
}
/**
* Stop scanning for devices
*/
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
fun stopScan() {
scanTimeoutJob?.cancel()
scanTimeoutJob = null
scanJob?.cancel()
bluetoothAdapter?.bluetoothLeScanner?.stopScan(platformScanCallback)
platformScanCallback = null
connectionCallback?.onScanCompleted(discoveredDevices.values.toList())
}
/**
* Connect to a specific device
*/
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun connect(device: EUCDevice) {
if (connectionState != BLEConstants.ConnectionState.DISCONNECTED) {
errorCallback?.onError(BLEException("Already connecting or connected"))
return
}
manualDisconnect = false
reconnectRetryCount = 0
currentDevice = device
connectionState = BLEConstants.ConnectionState.CONNECTING
connectionCallback?.onConnecting()
// Main connection job
connectionJob = coroutineScope.launch {
connectToDevice(device.bluetoothDevice!!)
}
connectionTimeoutJob = coroutineScope.launch {
delay(connectionTimeout.milliseconds)
if (connectionState == BLEConstants.ConnectionState.CONNECTING) {
disconnect()
errorCallback?.onError(BLEException("Connection timeout"))
}
}
}
/**
* Disconnect from current device
*/
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun disconnect() {
manualDisconnect = true
reconnectJob?.cancel()
reconnectJob = null
connectionTimeoutJob?.cancel()
connectionTimeoutJob = null
reconnectRetryCount = 0
cancelPollingOrchestration()
cancelDataFlowCollection()
connectionJob?.cancel()
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
currentDevice = null
currentProtocol = null
awaitingManualProtocolSelection = false
connectionState = BLEConstants.ConnectionState.DISCONNECTED
connectionCallback?.onDisconnected()
}
/**
* Send a command to the connected device
*/
fun getCommandSupport(commandType: CommandType): CommandSupport {
val protocol = currentProtocol ?: return CommandSupport.UNSUPPORTED
return protocol.getCommandSupport(commandType)
}
fun createCommand(commandType: CommandType, value: Any = Unit): ByteArray {
val protocol = currentProtocol ?: run {
errorCallback?.onError(BLEException("No protocol selected; cannot create command"))
return byteArrayOf()
}
if (protocol.getCommandSupport(commandType) == CommandSupport.UNSUPPORTED) {
errorCallback?.onError(
BLEException("Unsupported command '$commandType' for protocol ${protocol.manufacturer}")
)
return byteArrayOf()
}
val payload = protocol.createCommand(commandType, value)
if (payload.isEmpty()) {
errorCallback?.onError(
BLEException("Protocol ${protocol.manufacturer} returned empty payload for '$commandType'")
)
}
return payload
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun sendCommand(commandType: CommandType, value: Any = Unit) {
val protocol = currentProtocol ?: run {
errorCallback?.onError(BLEException("No protocol selected; cannot send command"))
return
}
val payload = createCommand(commandType, value)
if (payload.isEmpty()) return
sendCommand(payload, protocol.getWriteCharacteristicUUID())
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun sendCommand(command: ByteArray, characteristicUuid: UUID) {
if (connectionState != BLEConstants.ConnectionState.CONNECTED) {
errorCallback?.onError(BLEException("Not connected to a device"))
return
}
if (command.isEmpty()) {
errorCallback?.onError(BLEException("Cannot send empty command payload"))
return
}
val characteristic = getCharacteristic(characteristicUuid)
val payload = command.clone() // defensive copy
characteristic?.let { char ->
// ensure consistent write type (default)
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// new API 33+ overload
bluetoothGatt?.writeCharacteristic(char, payload, char.writeType)
} else {
// fallback for older APIs
@Suppress("DEPRECATION") run {
char.setValue(payload)
bluetoothGatt?.writeCharacteristic(char)
}
}
} ?: run {
errorCallback?.onError(BLEException("Characteristic not found: $characteristicUuid"))
}
}
/**
* Get current connection state
*/
fun getConnectionState(): BLEConstants.ConnectionState = connectionState
/**
* Get currently connected device
*/
fun getConnectedDevice(): EUCDevice? = currentDevice
/**
* Check if Bluetooth is enabled
*/
fun isBluetoothEnabled(): Boolean {
return bluetoothAdapter?.isEnabled == true
}
// Setters for configuration
fun setScanTimeout(timeout: Long) {
this.scanTimeout = timeout
}
fun setConnectionTimeout(timeout: Long) {
this.connectionTimeout = timeout
}
fun setAutoReconnect(enabled: Boolean) {
this.autoReconnect = enabled
}
fun setMaxRetries(retries: Int) {
this.maxRetries = retries
}
/**
* Callback registration methods.
*
* Threading contract:
* - Registered callbacks are invoked from background execution contexts.
* - No callback registered through these setters is guaranteed to run on the
* Android main thread.
* - UI updates must be marshalled explicitly by the caller.
*/
// Callback setters
fun setConnectionCallback(callback: ConnectionCallback) {
this.connectionCallback = callback
}
fun setDataCallback(callback: DataCallback) {
this.dataCallback = callback
}
fun setErrorCallback(callback: ErrorCallback) {
this.errorCallback = callback
}
// Private implementation methods
@RequiresApi(Build.VERSION_CODES.M)
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
//@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun startBleScan() {
val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT).build()
platformScanCallback = object : AndroidScanCallback() {
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onScanResult(callbackType: Int, result: ScanResult) {
processScanResult(result)
}
override fun onScanFailed(errorCode: Int) {
errorCallback?.onError(BLEException("Scan failed with error: $errorCode"))
}
}
scanner.startScan(null, settings, platformScanCallback)
connectionCallback?.onScanStarted()
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun processScanResult(result: ScanResult) {
val device = result.device
val name = device.name ?: result.scanRecord?.deviceName ?: "Unknown"
val eucDevice = EUCDevice(
bluetoothDevice = device,
name = name,
address = device.address,
manufacturerId = 0, // inconnu jusqu'à la connexion GATT
manufacturerData = null, // idem
rssi = result.rssi
)
discoveredDevices[eucDevice.address] = eucDevice
connectionCallback?.onDeviceDiscovered(eucDevice)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun connectToDevice(device: BluetoothDevice) {
try {
bluetoothGatt = device.connectGatt(context, false, this)
} catch (e: Exception) {
errorCallback?.onError(BLEException("Failed to connect to device: ${e.message}"))
disconnect()
}
}
private fun getCharacteristic(uuid: UUID): BluetoothGattCharacteristic? {
return bluetoothGatt?.services?.flatMap { service ->
service.characteristics
}?.find { characteristic ->
characteristic.uuid == uuid
}
}
// BluetoothGattCallback implementations
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
//super.onConnectionStateChange(gatt, status, newState)
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
// reset des compteurs de reconnexion
reconnectRetryCount = 0
reconnectJob?.cancel()
reconnectJob = null
connectionState = BLEConstants.ConnectionState.CONNECTED
connectionTimeoutJob?.cancel()
connectionTimeoutJob = null
connectionCallback?.onConnected()
// Discover services
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
cancelPollingOrchestration()
connectionState = BLEConstants.ConnectionState.DISCONNECTED
connectionCallback?.onDisconnected()
if (!manualDisconnect && autoReconnect && currentDevice != null) {
scheduleReconnect()
} else {
// if we don't want to reconnect, reset the counter
reconnectRetryCount = 0
}
}
BluetoothProfile.STATE_CONNECTING -> {
connectionState = BLEConstants.ConnectionState.CONNECTING
connectionCallback?.onConnecting()
}
BluetoothProfile.STATE_DISCONNECTING -> {
connectionState = BLEConstants.ConnectionState.DISCONNECTING
}
}
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun scheduleReconnect() {
// If already in progress or max retries reached, do not reschedule
if (reconnectJob != null) return
if (reconnectRetryCount >= maxRetries) {
connectionCallback?.onConnectionFailed(BLEException("Reconnection failed after $reconnectRetryCount attempts"))
reconnectRetryCount = 0
return
}
// calculate delay with backoff and jitter
val multiplier = 1L shl reconnectRetryCount.coerceAtMost(30) // prevent overflow
val baseDelay = (reconnectBaseDelayMs * multiplier).coerceAtMost(maxReconnectDelayMs)
val jitter = kotlin.random.Random.nextLong(0, BLEConstants.RECONNECT_JITTER_MAX_MS)
val delayMs = (baseDelay + jitter).coerceAtMost(maxReconnectDelayMs)
reconnectJob = coroutineScope.launch {
kotlinx.coroutines.delay(delayMs)
// double-check conditions avant tentative
if (manualDisconnect) {
reconnectJob = null
return@launch
}
if (connectionState == BLEConstants.ConnectionState.CONNECTED) {
reconnectJob = null
reconnectRetryCount = 0
return@launch
}
reconnectRetryCount++
connectionState = BLEConstants.ConnectionState.CONNECTING
connectionCallback?.onConnecting()
try {
currentDevice?.bluetoothDevice?.let { device ->
connectToDevice(device)
} ?: run {
connectionCallback?.onConnectionFailed(BLEException("No device to reconnect"))
reconnectJob = null
}
} catch (e: Exception) {
// in case of immediate failure, let the next onConnectionStateChange trigger a new attempt
reconnectJob = null
}
}
}
// Optional: helper to explicitly cancel reconnection (used by cleanup if needed)
private fun cancelReconnectAttempts() {
reconnectJob?.cancel()
reconnectJob = null
reconnectRetryCount = 0
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
//super.onServicesDiscovered(gatt, status)
if (status != BluetoothGatt.GATT_SUCCESS) {
errorCallback?.onError(BLEException("Service discovery failed: $status"))
disconnect()
return
}
val device = currentDevice
if (device == null) {
errorCallback?.onError(BLEException("No connected device available for protocol selection"))
disconnect()
return
}
// Handle forced protocol mode
if (protocolSelectionMode == ProtocolSelectionMode.FORCED) {
val forced = forcedProtocol
if (forced == null) {
errorCallback?.onError(BLEException("Forced protocol is not registered"))
disconnect()
return
}
forced.getCandidateDataCharacteristicUUIDs().distinct()
.forEach { enableNotifications(it) }
connectionCallback?.onServicesDiscovered(gatt.services)
if (!activateProtocolIfReady(
forced,
ProtocolSelectionReason.FORCED,
"forced override"
)
) {
disconnect()
}
return
}
// Try GATT fingerprint matching — primary automatic selection strategy
logger.debug(TAG + ":onServicesDiscovered", "NB Found services: ${gatt.services.size}")
logger.debug(TAG + ":onServicesDiscovered", "Services: ${gatt.services}")
gatt.services.forEach { service ->
logger.debug(TAG + ":onServicesDiscovered", "Service UUID: ${service.uuid}")
// Optional: Log characteristics count
logger.debug(
TAG + ":onServicesDiscovered",
" -> ${service.characteristics.size} characteristics"
)
service.characteristics.forEach { characteristic ->
logger.debug(
TAG + ":onServicesDiscovered",
" -> ${characteristic.uuid}"
)
}
}
val fingerprintMatchRaw = selectByGattFingerprint(gatt.services)
val fingerprintMatch = fingerprintMatchRaw.first
val fingerprintMatchVersion = fingerprintMatchRaw.second
logger.info(
TAG,
"FOUND protocol ${fingerprintMatch?.javaClass?.simpleName ?: "NONE"} with version $fingerprintMatchVersion"
)
connectionCallback?.onServicesDiscovered(gatt.services)
if (fingerprintMatch != null) {
// Refine with device name when a more-specific sub-protocol is registered
val subclassOverride = selectSubclassByDeviceName(fingerprintMatch, device.name)
val selected = subclassOverride ?: fingerprintMatch
if (selected is InMotionProtocol) {
logger.info(TAG, "Found inmotion protocol")
selected.setDialect(fingerprintMatchVersion)
}
val reason = if (subclassOverride != null) ProtocolSelectionReason.AUTO_DEVICE_NAME
else ProtocolSelectionReason.AUTO_GATT_FINGERPRINT
val logReason = if (subclassOverride != null) "GATT fingerprint + device name"
else "GATT fingerprint"
selected.getCandidateDataCharacteristicUUIDs().distinct()
.forEach { enableNotifications(it) }
if (!activateProtocolIfReady(selected, reason, logReason)) {
disconnect()
}
} else {
// No fingerprint match — try device name matching before falling back to manual
logger.info(TAG,"Trying to get protocol for device ${device.name}")
val deviceNameMatch = selectByDeviceName(device.name)
if (deviceNameMatch != null) {
deviceNameMatch.getCandidateDataCharacteristicUUIDs().distinct()
.forEach { enableNotifications(it) }
if (!activateProtocolIfReady(
deviceNameMatch,
ProtocolSelectionReason.AUTO_DEVICE_NAME,
"device name"
)
) {
disconnect()
}
} else {
// No automatic match — caller must choose manually
when (protocolSelectionMode) {
ProtocolSelectionMode.AUTO_WITH_MANUAL_FALLBACK -> {
protocols.forEach { proto ->
proto.getCandidateDataCharacteristicUUIDs().distinct()
.forEach { enableNotifications(it) }
}
notifyProtocolSelectionRequired()
}
else -> {
errorCallback?.onError(BLEException("No protocol found for this device; register its fingerprint or use AUTO_WITH_MANUAL_FALLBACK mode"))
disconnect()
}
}
}
}
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun onCharacteristicChanged(
gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray
) {
//super.onCharacteristicChanged(gatt, characteristic, value)
val data = value.clone()
handleIncomingBytes(data)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic
) {
//super.onCharacteristicChanged(gatt, characteristic)
val raw = characteristic.value ?: return
val data = raw.clone()
handleIncomingBytes(data)
}
@VisibleForTesting(otherwise = PRIVATE)
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
internal fun handleIncomingBytes(data: ByteArray) {
_rawFrameFlow.tryEmit(data.clone())
currentProtocol?.let { protocol ->
try {
matchPendingQueries(protocol, data)
protocol.decode(data)
} catch (e: Exception) {
errorCallback?.onError(BLEException("Data decoding failed: ${e.message}"))
}
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int
) {
//super.onCharacteristicWrite(gatt, characteristic, status)
if (status != BluetoothGatt.GATT_SUCCESS) {
errorCallback?.onError(BLEException("Characteristic write failed: $status"))
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
//super.onMtuChanged(gatt, mtu, status)
if (status == BluetoothGatt.GATT_SUCCESS) {
connectionCallback?.onMtuChanged(mtu)
} else {
errorCallback?.onError(BLEException("MTU change failed: $status"))
}
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun enableNotifications(characteristicUuid: UUID) {
val characteristic = getCharacteristic(characteristicUuid)
characteristic?.let { char ->
val cccdUuid = UUID.fromString(BLEConstants.CCCD_DESCRIPTOR)
val descriptor = char.getDescriptor(cccdUuid) ?: return@let
val enableValue =
ENABLE_NOTIFICATION_VALUE_COMPAT.clone() // defensive copy
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Use new API 33+ overload
bluetoothGatt?.writeDescriptor(descriptor, enableValue)
} else {
// Fallback for older APIs (setValue + writeDescriptor)
@Suppress("DEPRECATION") run {
descriptor.value = enableValue
bluetoothGatt?.writeDescriptor(descriptor)
}
}
// Enable notifications locally
bluetoothGatt?.setCharacteristicNotification(char, true)
}
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun startPollingOrchestration(protocol: EUCProtocol) {
cancelPollingOrchestration()
val plan = protocol.getPollingPlan()
if (!plan.enabled) return
queryOrchestrationJob = coroutineScope.launch {
for (query in plan.startupQueries) {
executeQueryWithRetry(protocol, query)
}
plan.periodicQueries.forEach { query ->
launch {
if (query.initialDelayMs > 0L) delay(query.initialDelayMs.milliseconds)
while (connectionState == BLEConstants.ConnectionState.CONNECTED) {
executeQueryWithRetry(protocol, query)
if (query.intervalMs <= 0L) break
delay(query.intervalMs.milliseconds)
}
}
}
}
}
private fun cancelPollingOrchestration() {
queryOrchestrationJob?.cancel()
queryOrchestrationJob = null
pendingQueries.clear()
}
@VisibleForTesting(otherwise = PRIVATE)
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
internal fun startDataFlowCollection(protocol: EUCProtocol) {
cancelDataFlowCollection()
dataFlowCollectorJob = coroutineScope.launch {
protocol.dataFlow.collect { d ->
dataCallback?.onDataReceived(d)
}
}
writeFlowCollectorJob = coroutineScope.launch {
protocol.writeFlow.collect { payload ->
if (payload.isNotEmpty()) {
sendCommand(payload, protocol.getWriteCharacteristicUUID())
}
}
}
}
@VisibleForTesting(otherwise = PRIVATE)
internal fun cancelDataFlowCollection() {
dataFlowCollectorJob?.cancel()
dataFlowCollectorJob = null
writeFlowCollectorJob?.cancel()
writeFlowCollectorJob = null
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun setActiveProtocol(
protocol: EUCProtocol,
reason: String,
selectionReason: ProtocolSelectionReason
) {
if (currentProtocol === protocol) return
currentProtocol = protocol
awaitingManualProtocolSelection = false
logger.info(TAG, "Selected protocol ${protocol.javaClass.simpleName} via $reason")
startPollingOrchestration(protocol)
startDataFlowCollection(protocol)
connectionCallback?.onProtocolSelected(buildProtocolSelection(protocol, selectionReason))
}
/**
* Attempts to identify the connected device's protocol by matching the discovered GATT
* service+characteristic profile against the [EucFingerprintDatabase].
*
* Returns the single matching protocol if exactly one protocol's fingerprint matches.
* Returns `null` when zero protocols match (unknown device) or when multiple protocols
* match the same profile (ambiguous — a warning is logged in that case).
*
* @param signaturesProvider Overridable signature lookup — defaults to [EucFingerprintDatabase].
* Exposed as a parameter for testing.
*/
@VisibleForTesting(otherwise = PRIVATE)
internal fun selectByGattFingerprint(
discoveredServices: List<BluetoothGattService>,
signaturesProvider: (String) -> List<GattSignature> = EucFingerprintDatabase::getSignatures
): Pair<EUCProtocol?, Int> {
val matchesWSign = protocols.map { protocol ->
var matches: Pair<EUCProtocol?, GattSignature?> = Pair(null,null)
val signatures = signaturesProvider(protocol.javaClass.simpleName)
logger.debug(
TAG + ":selectByGattFingerprint",
"$protocol nb signatures : ${signatures.size}"
)
signatures.forEach { signature ->
logger.debug(
TAG + ":selectByGattFingerprint",
"$protocol SIGNATURE : $signature"
)
}
signatures.isNotEmpty() && signatures.any { signature ->
logger.debug(
TAG + ":selectByGattFingerprint",
"$protocol TESTING SIGNATURE : $signature"
)
val match = matchesGattSignature(
discoveredServices,
signature,
protocol.javaClass.simpleName
)
if (match) {
matches = Pair(protocol, signature)
true
} else {
false
}
}
matches
}
val matches = matchesWSign.filter { match -> match.first != null }
return if (matches.size == 1) {
logger.info(
TAG + ":selectByGattFingerprint",
"GATT fingerprint uniquely matched protocol ${matches.single().first?.javaClass!!.simpleName}"
)
// return the protocol and the version of the first matching signature
Pair(matches.single().first, matches.single().second?.first()!!.version)
} else {
if (matches.size > 1) {
logger.warn(
TAG + ":selectByGattFingerprint",
"GATT fingerprint matched multiple protocols manual selection required"
)
matches.forEach { match ->
logger.warn(
TAG + ":selectByGattFingerprint",
"GATT fingerprint matched ${match.first?.javaClass!!.simpleName}"
)
}
}
Pair(null, 0)
}
}
/**
* Returns true if all [GattServiceSpec] entries in [signature] are satisfied by the
* discovered GATT services.
*/
private fun matchesGattSignature(
discoveredServices: List<BluetoothGattService>,
signature: GattSignature,
protocolName: String
): Boolean {
return signature.all { serviceSpec ->
logger.debug(
TAG + ":matchesGattSignature",
"CHECKING SIGNATURE FOR PROTOCOL $protocolName"
)
logger.debug(TAG + ":matchesGattSignature", "discoveredServices UUIDs")
discoveredServices.forEach { ds ->
logger.debug(TAG + ":matchesGattSignature", "${ds.uuid}")
}
logger.debug(TAG + ":matchesGattSignature", "Spec UUID : ${serviceSpec.uuid}")
val sf = discoveredServices.find { it.uuid == serviceSpec.uuid }
logger.debug(TAG + ":matchesGattSignature", "FOUND SERVICE$sf")
val service = discoveredServices.find { it.uuid == serviceSpec.uuid }
?: return@all false
logger.debug(
TAG + ":matchesGattSignature",
"$protocolName: FOUND SERVICE : ${serviceSpec.uuid}"
)
val presentCharUUIDs = service.characteristics.map { it.uuid }.toSet()
logger.debug(
TAG + ":matchesGattSignature",
"FOUND characteristics: ${presentCharUUIDs}"
)
val reqs = serviceSpec.requiredCharacteristicUUIDs.size
logger.debug(
TAG + ":matchesGattSignature",
"requiredCharacteristicUUIDs list: ${serviceSpec.requiredCharacteristicUUIDs}"
)
logger.debug(
TAG + ":matchesGattSignature",
"excludedCharacteristicUUIDs list: ${serviceSpec.excludedCharacteristicUUIDs}"
)
val excls = serviceSpec.excludedCharacteristicUUIDs.size
logger.debug(TAG + ":matchesGattSignature", "requiredCharacteristicUUIDs: $reqs")
var foundReqs = 0
var absentExcls = 0
val findReqs = serviceSpec.requiredCharacteristicUUIDs.map { req ->
if (req in presentCharUUIDs) {
logger.debug(
TAG + ":matchesGattSignature",
"FOUND REQUIRED CHAR : ${req} ${++foundReqs}/$reqs"
)
true
} else {
logger.debug(TAG + ":matchesGattSignature", "COULD NOT FIND: ${req}")
false
}
}
val findExcl = serviceSpec.excludedCharacteristicUUIDs.map { req ->
if (req in presentCharUUIDs) {
logger.debug(
TAG + ":matchesGattSignature",
"Expected excluded service found: ${req}"
)
false
} else {
logger.debug(
TAG + ":matchesGattSignature",
"Excluded service : ${req} ${++absentExcls}/$excls"
)
true
}
}
return@all findReqs.all { it } && findExcl.all { it }
}
}
/**
* Looks for a registered protocol that is a strict subclass of [baseProtocol]'s class
* and whose [EUCProtocol.matchesDeviceName] returns true for [deviceName].
*
* Used to refine a GATT fingerprint match with a more specific sub-protocol
* (e.g. GotwayProtocol → ExtremeBullProtocol when the device name contains "bull").
*
* Returns the single matching subclass, or null if none or multiple match.
* Exposed for testing.
*/
@VisibleForTesting(otherwise = PRIVATE)
internal fun selectSubclassByDeviceName(
baseProtocol: EUCProtocol,
deviceName: String
): EUCProtocol? {
val matches = protocols.filter { proto ->
proto.javaClass != baseProtocol.javaClass &&
baseProtocol.javaClass.isAssignableFrom(proto.javaClass) &&
proto.matchesDeviceName(deviceName)
}
return if (matches.size == 1) {
logger.info(
TAG,
"Device name '$deviceName' refined fingerprint match to subclass ${matches.single().javaClass.simpleName}"
)
matches.single()
} else {
if (matches.size > 1) {
logger.warn(
TAG,
"Device name '$deviceName' matched multiple subclass protocols (${matches.joinToString { it.javaClass.simpleName }}); using base protocol"
)
}
null
}
}
/**
* Attempts to identify the protocol solely by matching [deviceName] against all registered
* protocols' [EUCProtocol.matchesDeviceName] implementations.
*
* Used when GATT fingerprinting yields no match (e.g. Leaperkim/Nosfet share the
* same service UUID and cannot be disambiguated by GATT alone).
*
* Returns the single matching protocol, or null if none or multiple protocols match.
* Exposed for testing.
*/
@VisibleForTesting(otherwise = PRIVATE)
internal fun selectByDeviceName(deviceName: String): EUCProtocol? {
val matches = protocols.filter { it.matchesDeviceName(deviceName) }
return if (matches.size == 1) {
logger.info(
TAG,
"Device name '$deviceName' matched protocol ${matches.single().javaClass.simpleName}"
)
matches.single()
} else {
if (matches.size > 1) {
logger.warn(
TAG,
"Device name '$deviceName' matched multiple protocols (${matches.joinToString { it.javaClass.simpleName }}); ambiguous"
)
}
null
}
}
private fun protocolIdentifier(protocol: EUCProtocol): String {
return protocol.javaClass.simpleName.ifBlank { protocol.javaClass.name }
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun maybeActivateForcedProtocol(protocol: EUCProtocol? = forcedProtocol): Boolean? {
if (protocolSelectionMode != ProtocolSelectionMode.FORCED) return null
val selectedProtocol = protocol ?: return false
val servicesReady =
currentDevice != null && bluetoothGatt?.services?.isNotEmpty() == true
if (!servicesReady) return null
selectedProtocol.getCandidateDataCharacteristicUUIDs()
.distinct()
.forEach { characteristicUuid ->
enableNotifications(characteristicUuid)
}
return activateProtocolIfReady(
selectedProtocol,
ProtocolSelectionReason.FORCED,
"forced override"
)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun activateProtocolIfReady(
protocol: EUCProtocol,
selectionReason: ProtocolSelectionReason,
logReason: String
): Boolean {
val hasReadableCharacteristic =
protocol.getCandidateDataCharacteristicUUIDs().any { uuid ->
getCharacteristic(uuid) != null
}
if (!hasReadableCharacteristic) {
errorCallback?.onError(
BLEException("Protocol ${protocolIdentifier(protocol)} data characteristic is unavailable on this device")
)
return false
}
if (getCharacteristic(protocol.getWriteCharacteristicUUID()) == null) {
errorCallback?.onError(
BLEException("Protocol ${protocolIdentifier(protocol)} write characteristic is unavailable on this device")
)
return false
}
logger.info(TAG,"Applied protocol $protocol")
setActiveProtocol(protocol, logReason, selectionReason)
return true
}
private fun notifyProtocolSelectionRequired() {
if (protocolSelectionMode != ProtocolSelectionMode.AUTO_WITH_MANUAL_FALLBACK || currentProtocol != null) return
if (protocols.isEmpty()) return
if (awaitingManualProtocolSelection) return
awaitingManualProtocolSelection = true
connectionCallback?.onProtocolSelectionRequired(protocols.toList())
}
private fun buildProtocolSelection(
protocol: EUCProtocol,
reason: ProtocolSelectionReason
): ProtocolSelection {
return ProtocolSelection(
protocolId = protocolIdentifier(protocol),
manufacturer = protocol.manufacturer,
protocolClassName = protocol.javaClass.name,
reason = reason
)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private suspend fun executeQueryWithRetry(protocol: EUCProtocol, query: ProtocolQuerySpec) {
val retryCount = query.maxRetries.coerceAtLeast(0)
val totalAttempts = retryCount + MIN_QUERY_ATTEMPTS
for (attempt in 1..totalAttempts) {
if (connectionState != BLEConstants.ConnectionState.CONNECTED) return
val sent = sendProtocolQuery(protocol, query, attempt)
if (!sent) return
val timeoutMs = query.responseTimeoutMs.coerceAtLeast(MIN_QUERY_TIMEOUT_MS)
delay(timeoutMs.milliseconds)
if (!pendingQueries.containsKey(query.id)) {
return
}
pendingQueries.remove(query.id)
emitQueryTrace(
QueryTraceEvent(
timestampMs = System.currentTimeMillis(),
protocol = protocol.manufacturer,
queryId = query.id,
commandType = query.commandType,
phase = QueryTracePhase.TIMEOUT,
attempt = attempt,
message = "No matching response after ${timeoutMs}ms"
)
)
if (attempt < totalAttempts) {
emitQueryTrace(
QueryTraceEvent(
timestampMs = System.currentTimeMillis(),
protocol = protocol.manufacturer,
queryId = query.id,
commandType = query.commandType,
phase = QueryTracePhase.RETRY_SCHEDULED,
attempt = attempt + 1,
message = "Retry scheduled in ${query.retryBackoffMs}ms"
)
)
if (query.retryBackoffMs > 0L) delay(query.retryBackoffMs.milliseconds)
} else {
emitQueryTrace(
QueryTraceEvent(
timestampMs = System.currentTimeMillis(),
protocol = protocol.manufacturer,
queryId = query.id,
commandType = query.commandType,
phase = QueryTracePhase.FAILED,
attempt = attempt,
message = "Query exhausted retries"
)
)
}
}
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
private fun sendProtocolQuery(
protocol: EUCProtocol, query: ProtocolQuerySpec, attempt: Int
): Boolean {
if (protocol.getCommandSupport(query.commandType) == CommandSupport.UNSUPPORTED) {
emitQueryTrace(
QueryTraceEvent(
timestampMs = System.currentTimeMillis(),
protocol = protocol.manufacturer,
queryId = query.id,
commandType = query.commandType,
phase = QueryTracePhase.UNSUPPORTED,
attempt = attempt,
message = "Command is unsupported by protocol support matrix"
)
)
return false
}
val payload = protocol.createCommand(query.commandType, query.value)
if (payload.isEmpty()) {
emitQueryTrace(
QueryTraceEvent(
timestampMs = System.currentTimeMillis(),
protocol = protocol.manufacturer,
queryId = query.id,
commandType = query.commandType,
phase = QueryTracePhase.UNSUPPORTED,
attempt = attempt,
message = "Command payload is empty"
)
)
return false
}
sendCommand(payload, protocol.getWriteCharacteristicUUID())
pendingQueries[query.id] = PendingQueryState(
protocolName = protocol.manufacturer,
query = query,
attempt = attempt,
sentAtMs = System.currentTimeMillis()
)
emitQueryTrace(
QueryTraceEvent(
timestampMs = System.currentTimeMillis(),
protocol = protocol.manufacturer,
queryId = query.id,
commandType = query.commandType,
phase = QueryTracePhase.SENT,
attempt = attempt
)
)
return true
}
private fun matchPendingQueries(protocol: EUCProtocol, data: ByteArray) {
if (pendingQueries.isEmpty()) return
val now = System.currentTimeMillis()
val matchedIds = mutableListOf<String>()
for ((id, state) in pendingQueries) {
if (state.protocolName != protocol.manufacturer) continue
if (!protocol.matchesQueryResponse(state.query, data)) continue
matchedIds.add(id)
val rawLatency = now - state.sentAtMs
if (rawLatency < 0L) {
logger.warn(
"BLEQueryTrace",
"Negative latency detected for query=${state.query.id} protocol=${state.protocolName}; sentAt=${state.sentAtMs} now=$now"
)
}
emitQueryTrace(
QueryTraceEvent(
timestampMs = now,
protocol = state.protocolName,
queryId = id,
commandType = state.query.commandType,
phase = QueryTracePhase.RESPONSE_MATCHED,
attempt = state.attempt,
latencyMs = if (rawLatency < 0L) 0L else rawLatency
)
)
}
matchedIds.forEach { pendingQueries.remove(it) }
}
private fun emitQueryTrace(event: QueryTraceEvent) {
_queryTraceFlow.tryEmit(event)
logger.info(
"BLEQueryTrace",
"phase=${event.phase} protocol=${event.protocol} query=${event.queryId} command=${event.commandType} attempt=${event.attempt} latencyMs=${event.latencyMs ?: -1} message=${event.message ?: ""}"
)
}
// Cleanup
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun cleanup() {
cancelReconnectAttempts()
scanTimeoutJob?.cancel()
connectionTimeoutJob?.cancel()
disconnect()
scanJob?.cancel()
connectionJob?.cancel()
coroutineScope.cancel()
}
}
/**
* Scan-related callbacks exposed by [BLEManager].
*
* Threading contract:
* - These callbacks are invoked from background execution contexts.
* - They are not guaranteed to run on the Android main thread.
*
* Implications for callers:
* - Do not update Android views directly from these methods unless you explicitly
* switch to the main thread first.
* - Prefer forwarding these events to a `Flow`, `StateFlow`, `LiveData`, or another
* application-level state container consumed by the UI layer.
*/
// Callback interfaces
interface ScanCallback {
fun onScanStarted() {}
fun onDeviceDiscovered(device: EUCDevice) {}
fun onScanCompleted(devices: List<EUCDevice>) {}
}
/**
* Connection- and GATT-related callbacks exposed by [BLEManager].
*
* This callback extends [ScanCallback], so it also receives scan lifecycle events.
*
* Threading contract:
* - Methods may be invoked either from Android BLE callback paths or from the
* manager's background coroutine scope.
* - No method in this callback is guaranteed to run on the Android main thread.
*
* Recommended usage:
* - Perform non-UI work directly here if needed.
* - For UI updates, switch explicitly to `Dispatchers.Main`, `runOnUiThread`, or
* publish the event into a UI-observed state holder.
*/
abstract class ConnectionCallback : io.github.tritbool.euc.ble.core.ScanCallback {
open fun onConnecting() {}
open fun onConnected() {}
open fun onDisconnected() {}
open fun onConnectionFailed(error: BLEException) {}
open fun onServicesDiscovered(services: List<BluetoothGattService>) {}
open fun onMtuChanged(mtu: Int) {}
open fun onProtocolSelectionRequired(protocols: List<EUCProtocol>) {}
open fun onProtocolSelected(selection: ProtocolSelection) {}
}
/**
* Default adapter that forwards [ConnectionCallback] events to a [BleBackendListener].
*
* Threading contract:
* - Forwarded events keep the original threading behavior of [BLEManager].
* - Listener methods are therefore not guaranteed to run on the Android main thread.
*
* Consumers of [BleBackendListener] must explicitly switch to the main thread before
* performing UI work.
*/
class ListenerConnectionCallback(
private val listener: BleBackendListener?, private val bleManager: BLEManager
) : ConnectionCallback() {
override fun onScanStarted() {
listener?.onEvent(BleBackendEvent.ScanStarted)
}
override fun onDeviceDiscovered(device: EUCDevice) {
listener?.onEvent(BleBackendEvent.DeviceDiscovered(device))
}
override fun onScanCompleted(devices: List<EUCDevice>) {
listener?.onEvent(BleBackendEvent.ScanCompleted(devices))
}
override fun onConnecting() {
listener?.onEvent(BleBackendEvent.Connecting)
}
override fun onConnected() {
listener?.onEvent(BleBackendEvent.Connected(bleManager.getConnectedDevice()))
}
override fun onDisconnected() {
listener?.onEvent(BleBackendEvent.Disconnected)
}
override fun onConnectionFailed(error: BLEException) {
listener?.onEvent(BleBackendEvent.Error(error))
}
override fun onServicesDiscovered(services: List<BluetoothGattService>) {
listener?.onEvent(BleBackendEvent.ServicesDiscovered(services.map { it.uuid }))
}
override fun onMtuChanged(mtu: Int) {
listener?.onEvent(BleBackendEvent.MtuChanged(mtu))
}
override fun onProtocolSelectionRequired(protocols: List<EUCProtocol>) {
listener?.onEvent(BleBackendEvent.ProtocolSelectionRequired(protocols))
}
override fun onProtocolSelected(selection: ProtocolSelection) {
listener?.onEvent(BleBackendEvent.ProtocolSelected(selection))
}
}
/**
* Callback receiving decoded [EUCData] frames.
*
* Threading contract:
* - Invoked from the manager's background coroutine scope.
* - Not guaranteed to run on the Android main thread.
*
* Recommended usage:
* - Perform parsing, aggregation, logging, persistence, or domain processing directly.
* - For UI updates, switch explicitly to the main thread or publish the value through
* a UI-observed state container.
*/
interface DataCallback {
fun onDataReceived(data: EUCData)
}
/**
* Callback receiving errors produced by [BLEManager].
*
* Threading contract:
* - Invoked from background execution contexts.
* - Not guaranteed to run on the Android main thread.
*
* Recommended usage:
* - Handle logging, metrics, and state transitions directly.
* - Switch explicitly to the main thread before displaying Android UI such as
* dialogs, snackbars, or toasts.
*/
interface ErrorCallback {
fun onError(error: BLEException)
}
enum class ProtocolSelectionMode {
AUTO,
AUTO_WITH_MANUAL_FALLBACK,
FORCED
}
enum class ProtocolSelectionReason {
AUTO_GATT_FINGERPRINT,
AUTO_DEVICE_NAME,
MANUAL_FALLBACK,
FORCED
}
data class ProtocolSelection(
val protocolId: String,
val manufacturer: String,
val protocolClassName: String,
val reason: ProtocolSelectionReason
)
enum class QueryTracePhase {
SENT, RESPONSE_MATCHED, TIMEOUT, RETRY_SCHEDULED, UNSUPPORTED, FAILED
}
data class QueryTraceEvent(
val timestampMs: Long,
val protocol: String,
val queryId: String,
val commandType: CommandType,
val phase: QueryTracePhase,
val attempt: Int,
val latencyMs: Long? = null,
val message: String? = null
)