feat: Migrate project to Kotlin Multiplatform (KMP) architecture (#4738)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-06 20:43:45 -06:00 committed by GitHub
parent 182ad933f4
commit 0ce322a0f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
163 changed files with 1837 additions and 877 deletions

View file

@ -31,77 +31,63 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
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 no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.AndroidBleService
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
import kotlin.time.Duration.Companion.seconds
/**
* BLE transport implementation for ESP32 Unified OTA protocol. Uses Nordic Kotlin-BLE-Library for modern coroutine
* support.
* BLE transport implementation for ESP32 Unified OTA protocol.
*
* Service UUID: 4FAFC201-1FB5-459E-8FCC-C5C9C331914B
* - OTA Characteristic (Write): 62ec0272-3ec5-11eb-b378-0242ac130005
* - TX Characteristic (Notify): 62ec0272-3ec5-11eb-b378-0242ac130003
*/
class BleOtaTransport(
private val centralManager: CentralManager,
private val scanner: BleScanner,
connectionFactory: BleConnectionFactory,
private val address: String,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : UnifiedOtaProtocol {
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
private val bleConnection = BleConnection(centralManager, transportScope, "BLE OTA")
private val bleConnection = connectionFactory.create(transportScope, "BLE OTA")
private var otaCharacteristic: RemoteCharacteristic? = null
private val responseChannel = Channel<String>(Channel.UNLIMITED)
private var isConnected = false
/**
* Scan for the device by MAC address with retries. After reboot, the device needs time to come up in OTA mode.
*
* Note: We scan by address rather than service UUID because some ESP32 OTA bootloaders don't include the service
* UUID in their advertisement data - the service is only discoverable after connecting. We verify the OTA service
* exists after connection.
*
* ESP32 bootloaders may use the original MAC address OR increment the last byte by 1 for OTA mode, so we check both
* addresses.
*/
private suspend fun scanForOtaDevice(): Peripheral? {
/** Scan for the device by MAC address with retries. After reboot, the device needs time to come up in OTA mode. */
private suspend fun scanForOtaDevice(): BleDevice? {
// ESP32 OTA bootloader may use MAC address with last byte incremented by 1
val otaAddress = calculateOtaAddress(macAddress = address)
val targetAddresses = setOf(address, otaAddress)
Logger.i { "BLE OTA: Will match addresses: $targetAddresses" }
val scanner = BleScanner(centralManager)
repeat(SCAN_RETRY_COUNT) { attempt ->
Logger.i { "BLE OTA: Scanning for device (attempt ${attempt + 1}/$SCAN_RETRY_COUNT)..." }
// Scan without service UUID filter - ESP32 OTA bootloader may not advertise the UUID
// Log all devices found during scan for debugging
val foundDevices = mutableSetOf<String>()
val peripheral =
val device =
scanner
.scan(SCAN_TIMEOUT)
.onEach { p ->
if (foundDevices.add(p.address)) {
Logger.d { "BLE OTA: Scan found device: ${p.address} (name=${p.name})" }
.onEach { d ->
if (foundDevices.add(d.address)) {
Logger.d { "BLE OTA: Scan found device: ${d.address} (name=${d.name})" }
}
}
.firstOrNull { it.address in targetAddresses }
if (peripheral != null) {
Logger.i { "BLE OTA: Found target device at ${peripheral.address}" }
return peripheral
if (device != null) {
Logger.i { "BLE OTA: Found target device at ${device.address}" }
return device
}
Logger.w { "BLE OTA: Target addresses $targetAddresses not in ${foundDevices.size} devices found" }
@ -136,8 +122,7 @@ class BleOtaTransport(
Logger.i { "BLE OTA: Connecting to $address using Nordic BLE Library..." }
// Scan for device by address - device must have rebooted into OTA mode
val p =
val device =
scanForOtaDevice()
?: throw OtaProtocolException.ConnectionFailed(
"Device not found at address $address. " +
@ -147,41 +132,39 @@ class BleOtaTransport(
bleConnection.connectionState
.onEach { state ->
Logger.d { "BLE OTA: Connection state changed to $state" }
isConnected = state is ConnectionState.Connected
isConnected = state is BleConnectionState.Connected
}
.launchIn(transportScope)
try {
val finalState = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
if (finalState is ConnectionState.Disconnected) {
Logger.w { "BLE OTA: Failed to connect to ${p.address} (state=$finalState)" }
throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${p.address}")
val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (finalState is BleConnectionState.Disconnected) {
Logger.w { "BLE OTA: Failed to connect to ${device.address} (state=$finalState)" }
throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${device.address}")
}
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
Logger.w { "BLE OTA: Timed out waiting to connect to ${p.address}. Error: ${e.message}" }
throw OtaProtocolException.Timeout("Timed out connecting to device at address ${p.address}")
Logger.w { "BLE OTA: Timed out waiting to connect to ${device.address}. Error: ${e.message}" }
throw OtaProtocolException.Timeout("Timed out connecting to device at address ${device.address}")
}
Logger.i { "BLE OTA: Connected to ${p.address}, discovering services..." }
// Increase connection priority for OTA
bleConnection.requestConnectionPriority(ConnectionPriority.HIGH)
Logger.i { "BLE OTA: Connected to ${device.address}, discovering services..." }
// Discover services using our unified profile helper
bleConnection.profile(OTA_SERVICE_UUID) { service ->
val androidService = (service as AndroidBleService).service
val ota =
requireNotNull(service.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
"OTA characteristic not found"
}
val txChar =
requireNotNull(service.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
"TX characteristic not found"
}
otaCharacteristic = ota
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "BLE OTA: Service ready. Max write value length: $maxLen bytes" }
// Enable notifications and collect responses
@ -211,13 +194,7 @@ class BleOtaTransport(
}
}
/**
* Initiates the OTA update by sending the size and hash.
*
* Note: If the start command is fragmented into multiple BLE packets, the protocol may send multiple responses
* (usually one ACK per packet followed by a final OK/ERASING).
*/
@Suppress("CyclomaticComplexMethod")
/** Initiates the OTA update by sending the size and hash. */
override suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
@ -233,7 +210,6 @@ class BleOtaTransport(
responsesReceived++
when (val parsed = OtaResponse.parse(response)) {
is OtaResponse.Ok -> {
// Only consider handshake complete after consuming all potential fragmented responses
if (responsesReceived >= packetsSent) {
handshakeComplete = true
}
@ -258,14 +234,7 @@ class BleOtaTransport(
}
}
/**
* Streams the firmware data in chunks.
*
* Each chunk is potentially fragmented into multiple BLE packets based on the negotiated MTU. The transport ensures
* that every fragmented packet is acknowledged by the device before proceeding, preventing buffer overflows on the
* radio.
*/
@Suppress("CyclomaticComplexMethod")
/** Streams the firmware data in chunks. */
override suspend fun streamFirmware(
data: ByteArray,
chunkSize: Int,
@ -283,10 +252,10 @@ class BleOtaTransport(
val currentChunkSize = minOf(chunkSize, remainingBytes)
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
// Write chunk (potentially fragmented into multiple BLE packets)
val packetsSentForChunk = writeData(chunk, WriteType.WITHOUT_RESPONSE)
// Write chunk
val packetsSentForChunk = writeData(chunk, BleWriteType.WITHOUT_RESPONSE)
// Wait for responses (The protocol expects one response per GATT write)
// Wait for responses
val nextSentBytes = sentBytes + currentChunkSize
repeat(packetsSentForChunk) { i ->
val response = waitForResponse(ACK_TIMEOUT_MS)
@ -298,15 +267,10 @@ class BleOtaTransport(
}
is OtaResponse.Ok -> {
// OK indicates completion (usually on last packet of last chunk)
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
sentBytes = nextSentBytes
onProgress(1.0f)
return@runCatching Unit
} else if (!isLastPacketOfChunk) {
// Intermediate OK might happen if the device treats packets as chunks
} else {
throw OtaProtocolException.TransferFailed("Premature OK received at offset $nextSentBytes")
}
}
@ -325,7 +289,6 @@ class BleOtaTransport(
onProgress(sentBytes.toFloat() / totalBytes)
}
// If we finished the loop without receiving OK, wait for it now (verification stage)
val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS)
when (val parsed = OtaResponse.parse(finalResponse)) {
is OtaResponse.Ok -> Unit
@ -348,16 +311,10 @@ class BleOtaTransport(
private suspend fun sendCommand(command: OtaCommand): Int {
val data = command.toString().toByteArray()
return writeData(data, WriteType.WITH_RESPONSE)
return writeData(data, BleWriteType.WITH_RESPONSE)
}
/**
* Writes data to the OTA characteristic, fragmenting the data into multiple BLE packets if it exceeds the
* negotiated MTU (maximum write length).
*
* @return The number of packets sent.
*/
private suspend fun writeData(data: ByteArray, writeType: WriteType): Int {
private suspend fun writeData(data: ByteArray, writeType: BleWriteType): Int {
val characteristic =
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
@ -369,7 +326,14 @@ class BleOtaTransport(
while (offset < data.size) {
val chunkSize = minOf(data.size - offset, maxLen)
val packet = data.copyOfRange(offset, offset + chunkSize)
characteristic.write(packet, writeType = writeType)
val nordicWriteType =
when (writeType) {
BleWriteType.WITH_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITH_RESPONSE
BleWriteType.WITHOUT_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITHOUT_RESPONSE
}
characteristic.write(packet, writeType = nordicWriteType)
offset += chunkSize
packetsSent++
}
@ -389,17 +353,14 @@ class BleOtaTransport(
// Timeouts and retries
private val SCAN_TIMEOUT = 10.seconds
private const val CONNECTION_TIMEOUT_MS = 15_000L
private const val ERASING_TIMEOUT_MS = 60_000L // Flash erase can take a while
private const val ERASING_TIMEOUT_MS = 60_000L
private const val ACK_TIMEOUT_MS = 10_000L
private const val VERIFICATION_TIMEOUT_MS = 10_000L
// Reboot and scan retry configuration
// Device needs time to reboot into OTA mode after receiving the reboot command
private const val REBOOT_DELAY_MS = 5_000L
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 2_000L
// Recommended chunk size for BLE
const val RECOMMENDED_CHUNK_SIZE = 512
}
}

View file

@ -26,8 +26,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
@ -73,7 +74,8 @@ constructor(
private val firmwareRetriever: FirmwareRetriever,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val centralManager: CentralManager,
private val bleScanner: BleScanner,
private val bleConnectionFactory: BleConnectionFactory,
@ApplicationContext private val context: Context,
) : FirmwareUpdateHandler {
@ -101,7 +103,7 @@ constructor(
hardware = hardware,
updateState = updateState,
firmwareUri = firmwareUri,
transportFactory = { BleOtaTransport(centralManager, address) },
transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) },
rebootMode = 1,
connectionAttempts = 5,
)

View file

@ -100,7 +100,9 @@ class BleOtaTransportErrorTest {
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
try {
transport.connect().getOrThrow()
@ -162,7 +164,9 @@ class BleOtaTransportErrorTest {
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
try {
transport.connect().getOrThrow()
@ -243,7 +247,9 @@ class BleOtaTransportErrorTest {
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
try {
transport.connect().getOrThrow()

View file

@ -80,7 +80,9 @@ class BleOtaTransportMtuTest {
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
transport.connect().getOrThrow()

View file

@ -144,7 +144,9 @@ class BleOtaTransportNordicMockTest {
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
// 1. Connect
val connectResult = transport.connect()

View file

@ -96,7 +96,9 @@ class BleOtaTransportServiceDiscoveryTest {
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should fail when OTA service is missing", result.isFailure)
@ -135,7 +137,9 @@ class BleOtaTransportServiceDiscoveryTest {
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should fail when TX characteristic is missing", result.isFailure)
@ -148,7 +152,9 @@ class BleOtaTransportServiceDiscoveryTest {
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Don't simulate any peripherals — scan will find nothing
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should fail when device is not found", result.isFailure)
@ -200,7 +206,9 @@ class BleOtaTransportServiceDiscoveryTest {
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should succeed: ${result.exceptionOrNull()}", result.isSuccess)

View file

@ -103,7 +103,9 @@ class BleOtaTransportTest {
centralManager.simulatePeripherals(listOf(otaPeripheral))
val transport = BleOtaTransport(centralManager, address, testDispatcher)
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
// 1. Connect
transport.connect().getOrThrow()

View file

@ -26,7 +26,6 @@ import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
@ -45,12 +44,20 @@ class Esp32OtaUpdateHandlerTest {
private val firmwareRetriever: FirmwareRetriever = mockk()
private val radioController: RadioController = mockk()
private val nodeRepository: NodeRepository = mockk()
private val centralManager: CentralManager = mockk()
private val bleScanner: org.meshtastic.core.ble.BleScanner = mockk()
private val bleConnectionFactory: org.meshtastic.core.ble.BleConnectionFactory = mockk()
private val context: Context = mockk()
private val contentResolver: ContentResolver = mockk()
private val handler =
Esp32OtaUpdateHandler(firmwareRetriever, radioController, nodeRepository, centralManager, context)
Esp32OtaUpdateHandler(
firmwareRetriever,
radioController,
nodeRepository,
bleScanner,
bleConnectionFactory,
context,
)
@Before
fun setUp() {