refactor(ble): Centralize BLE logic into a core module (#4550)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-20 06:41:52 -06:00 committed by GitHub
parent 7a68802bc2
commit 6bfa5b5f70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 3471 additions and 2405 deletions

View file

@ -0,0 +1,144 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import kotlin.uuid.Uuid
private const val SERVICE_DISCOVERY_TIMEOUT_MS = 10_000L
/**
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
* discovery.
*
* @param centralManager The Nordic [CentralManager] to use for connection.
* @param scope The [CoroutineScope] in which to monitor connection state.
* @param tag A tag for logging.
*/
class BleConnection(
private val centralManager: CentralManager,
private val scope: CoroutineScope,
private val tag: String = "BLE",
) {
/** The currently connected [Peripheral], or null if not connected. */
var peripheral: Peripheral? = null
private set
private val _connectionState = simpleSharedFlow<ConnectionState>()
/** A flow of [ConnectionState] changes for the current [peripheral]. */
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
private var stateJob: Job? = null
/**
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
* Use [connectAndAwait] if you need to wait for the connection to be established.
*
* @param p The peripheral to connect to.
*/
suspend fun connect(p: Peripheral) {
stateJob?.cancel()
peripheral = p
centralManager.connect(
peripheral = p,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
stateJob =
p.state
.onEach { state ->
Logger.d { "[$tag] Connection state changed to $state" }
if (state is ConnectionState.Connected) {
p.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(p)
}
_connectionState.emit(state)
}
.launchIn(scope)
}
/**
* Connects to the given [Peripheral] and waits for a terminal state (Connected or Disconnected).
*
* @param p The peripheral to connect to.
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
* @return The final [ConnectionState].
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
*/
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
connect(p)
return withTimeout(timeoutMs) {
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
}
}
/** Discovers characteristics for a specific service with retries. */
@Suppress("ReturnCount")
suspend fun discoverCharacteristics(
serviceUuid: Uuid,
characteristicUuids: List<Uuid>,
): Map<Uuid, RemoteCharacteristic>? = retryBleOperation(tag = tag) {
val p = peripheral ?: return@retryBleOperation null
val services =
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
val service = services.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
for (uuid in characteristicUuids) {
val char = service.characteristics.find { it.uuid == uuid }
if (char != null) {
result[uuid] = char
}
}
return@retryBleOperation if (result.size == characteristicUuids.size) result else null
}
private fun observePeripheralDetails(p: Peripheral) {
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
.onEach { params -> Logger.i { "[$tag] BLE connection parameters changed to $params" } }
.launchIn(scope)
}
/** Disconnects from the current peripheral. */
suspend fun disconnect() {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()
peripheral = null
}
}

View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import no.nordicsemi.kotlin.ble.client.exception.ScanningException
import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
import no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException
import no.nordicsemi.kotlin.ble.core.exception.GattException
import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
/**
* Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more
* granular error handling and intelligent reconnection strategies.
*/
sealed class BleError(val message: String, val shouldReconnect: Boolean) {
/**
* An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a
* reconnect.
*/
data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false)
/**
* An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is
* warranted.
*/
class ConnectionFailed(exception: Throwable) :
BleError("Connection failed: ${exception.message}", shouldReconnect = true)
/**
* An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect
* attempt is warranted.
*/
class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true)
/**
* An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt
* is warranted.
*/
class Disconnected(reason: ConnectionState.Disconnected.Reason?) :
BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true)
/**
* Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error.
*
* @param exception The underlying GattException.
*/
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
/**
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
*
* @param exception The underlying BluetoothException.
*/
class BluetoothError(exception: BluetoothException) :
BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true)
/** The BLE manager was closed. This is a non-recoverable error. */
class ManagerClosed(exception: ManagerClosedException) :
BleError("Manager closed: ${exception.message}", shouldReconnect = false)
/** A BLE operation failed. This may be recoverable. */
class OperationFailed(exception: OperationFailedException) :
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
/**
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
*/
class InvalidAttribute(exception: InvalidAttributeException) :
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
/** An error occurred while scanning for devices. This may be recoverable. */
class Scanning(exception: ScanningException) :
BleError("Scanning error: ${exception.message}", shouldReconnect = true)
/** Bluetooth is unavailable on the device. This is a non-recoverable error. */
class BluetoothUnavailable(exception: BluetoothUnavailableException) :
BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false)
/** The peripheral is not connected. This may be recoverable. */
class PeripheralNotConnected(exception: PeripheralNotConnectedException) :
BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true)
/** A value did not match what was expected. This may be recoverable. */
class ValueDoesNotMatch(exception: ValueDoesNotMatchException) :
BleError("Value does not match: ${exception.message}", shouldReconnect = true)
/** A generic error for other exceptions that may occur. */
class GenericError(exception: Throwable) :
BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true)
companion object {
fun from(exception: Throwable): BleError = when (exception) {
is GattException -> {
when (exception) {
is ConnectionFailedException -> ConnectionFailed(exception)
is PeripheralNotConnectedException -> PeripheralNotConnected(exception)
is OperationFailedException -> OperationFailed(exception)
is ValueDoesNotMatchException -> ValueDoesNotMatch(exception)
else -> GattError(exception)
}
}
is BluetoothException -> {
when (exception) {
is BluetoothUnavailableException -> BluetoothUnavailable(exception)
is InvalidAttributeException -> InvalidAttribute(exception)
is ScanningException -> Scanning(exception)
else -> BluetoothError(exception)
}
}
else -> GenericError(exception)
}
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BleModule {
@Provides
@Singleton
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
@Provides
@Singleton
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* Retries a BLE operation a specified number of times with a delay between attempts.
*
* @param count The number of attempts to make.
* @param delayMs The delay in milliseconds between attempts.
* @param tag A tag for logging.
* @param block The operation to perform.
* @return The result of the operation.
* @throws Exception if the operation fails after all attempts.
*/
@Suppress("TooGenericExceptionCaught")
suspend fun <T> retryBleOperation(
count: Int = 3,
delayMs: Long = 500L,
tag: String = "BLE",
block: suspend () -> T,
): T {
var currentAttempt = 0
while (true) {
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
currentAttempt++
if (currentAttempt >= count) {
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
Logger.w(e) {
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
}
delay(delayMs)
}
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConjunctionFilterScope
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
import javax.inject.Inject
import kotlin.time.Duration
/**
* A wrapper around [CentralManager]'s scanning capabilities to provide a consistent and easy-to-use API for BLE
* scanning across the application.
*
* @param centralManager The Nordic [CentralManager] to use for scanning.
*/
class BleScanner @Inject constructor(private val centralManager: CentralManager) {
/**
* Scans for BLE devices.
*
* @param timeout The duration of the scan.
* @param filterBlock Optional filter configuration block.
* @return A [Flow] of discovered [Peripheral]s.
*/
fun scan(timeout: Duration, filterBlock: (ConjunctionFilterScope.() -> Unit)? = null): Flow<Peripheral> =
if (filterBlock != null) {
centralManager.scan(timeout, filterBlock)
} else {
centralManager.scan(timeout)
}
.distinctByPeripheral()
.map { it.peripheral }
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import dagger.Lazy
import javax.inject.Inject
/** BroadcastReceiver to handle Bluetooth adapter and device state changes. */
class BluetoothBroadcastReceiver @Inject constructor(private val bluetoothRepository: Lazy<BluetoothRepository>) :
BroadcastReceiver() {
val intentFilter: IntentFilter
get() =
IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
BluetoothAdapter.ACTION_STATE_CHANGED,
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
-> {
bluetoothRepository.get().refreshState()
}
}
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
import javax.inject.Singleton
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
@Singleton
class BluetoothRepository
@Inject
constructor(
private val dispatchers: CoroutineDispatchers,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
private val androidEnvironment: AndroidEnvironment,
) {
private val _state =
MutableStateFlow(
BluetoothState(
// Assume we have permission until we get our initial state update to prevent premature
// notifications to the user.
hasPermissions = true,
),
)
val state: StateFlow<BluetoothState> = _state.asStateFlow()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
}
}
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
/**
* Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding
* process is finished. After successful bonding, the repository's state is refreshed to include the new bonded
* device.
*
* @param peripheral The peripheral to bond with.
* @throws SecurityException if required Bluetooth permissions are not granted.
* @throws Exception if the bonding process fails.
*/
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
refreshState()
}
internal suspend fun updateBluetoothState() {
val hasPerms =
if (androidEnvironment.androidSdkVersion >= Build.VERSION_CODES.S) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {
androidEnvironment.isLocationPermissionGranted
}
val enabled = androidEnvironment.isBluetoothEnabled
val newState =
BluetoothState(
hasPermissions = hasPerms,
enabled = enabled,
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
)
_state.emit(newState)
Logger.d { "Detected our bluetooth access=$newState" }
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<Peripheral> =
if (enabled && hasPerms) {
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral)
} else {
emptyList()
}
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
return nameMatches || hasRequiredService
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import org.meshtastic.core.model.util.anonymize
/** A snapshot in time of the state of the bluetooth subsystem. */
data class BluetoothState(
/** Whether we have adequate permissions to query bluetooth state */
val hasPermissions: Boolean = false,
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a list of the currently bonded devices */
val bondedDevices: List<Peripheral> = emptyList(),
) {
override fun toString(): String =
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {
it.anonymize
}})"
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import kotlin.uuid.Uuid
/** Constants for Meshtastic Bluetooth LE interaction. */
object MeshtasticBleConstants {
/** Pattern for Meshtastic device names (e.g., Meshtastic_1234). */
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
/** The Meshtastic service UUID. */
val SERVICE_UUID: Uuid = Uuid.parse("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
/** Characteristic for sending data to the radio. */
val TORADIO_CHARACTERISTIC: Uuid = Uuid.parse("f75c76d2-129e-4dad-a1dd-7866124401e7")
/** Characteristic for receiving packet count notifications. */
val FROMNUM_CHARACTERISTIC: Uuid = Uuid.parse("ed9da18c-a800-4f66-a670-aa7547e34453")
/** Characteristic for reading data from the radio. */
val FROMRADIO_CHARACTERISTIC: Uuid = Uuid.parse("2c55e69e-4993-11ed-b878-0242ac120002")
/** Characteristic for receiving log notifications from the radio. */
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
}

View file

@ -0,0 +1,127 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.mock.mock
import no.nordicsemi.kotlin.ble.client.mock.AddressType
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
import no.nordicsemi.kotlin.ble.client.mock.Proximity
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.di.CoroutineDispatchers
@OptIn(ExperimentalCoroutinesApi::class)
class BluetoothRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, default = testDispatcher, io = testDispatcher)
private lateinit var mockEnvironment: MockAndroidEnvironment
private lateinit var lifecycleOwner: TestLifecycleOwner
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockEnvironment =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = true,
isBluetoothConnectPermissionGranted = true,
)
lifecycleOwner =
TestLifecycleOwner(initialState = Lifecycle.State.RESUMED, coroutineDispatcher = testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initial state reflects environment`() = runTest(testDispatcher) {
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
runCurrent()
val state = repository.state.value
assertTrue(state.enabled)
assertTrue(state.hasPermissions)
}
@Test
fun `state updates when bluetooth is disabled`() = runTest(testDispatcher) {
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
mockEnvironment.simulatePowerOff()
runCurrent()
val state = repository.state.value
assertFalse(state.enabled)
}
@Test
fun `bonded devices are correctly identified`() = runTest(testDispatcher) {
val address = "C0:00:00:00:00:03"
val peripheral =
PeripheralSpec.simulatePeripheral(
identifier = address,
addressType = AddressType.RANDOM_STATIC,
proximity = Proximity.IMMEDIATE,
) {
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
CompleteLocalName("Meshtastic_5678")
}
connectable(
name = "Meshtastic_5678",
isBonded = true,
eventHandler = object : PeripheralSpecEventHandler {},
) {
Service(uuid = SERVICE_UUID) {}
}
}
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
centralManager.simulatePeripherals(listOf(peripheral))
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
repository.refreshState()
runCurrent()
val state = repository.state.value
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
assertEquals(address, state.bondedDevices.first().address)
}
}