mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix(transport): Kable BLE audit + thread-safety, MQTT, and logging fixes across transport layers (#5071)
This commit is contained in:
parent
5f0e60eb21
commit
a3c0a4832d
44 changed files with 1123 additions and 513 deletions
|
|
@ -38,6 +38,9 @@ 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
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** BLE transport implementation for ESP32 Unified OTA protocol using Kable. */
|
||||
class BleOtaTransport(
|
||||
|
|
@ -68,7 +71,7 @@ class BleOtaTransport(
|
|||
tag = "BLE OTA",
|
||||
serviceUuid = OTA_SERVICE_UUID,
|
||||
retryCount = SCAN_RETRY_COUNT,
|
||||
retryDelayMs = SCAN_RETRY_DELAY_MS,
|
||||
retryDelay = SCAN_RETRY_DELAY,
|
||||
) {
|
||||
it.address in targetAddresses
|
||||
}
|
||||
|
|
@ -76,8 +79,8 @@ class BleOtaTransport(
|
|||
|
||||
@Suppress("MagicNumber")
|
||||
override suspend fun connect(): Result<Unit> = runCatching {
|
||||
Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." }
|
||||
delay(REBOOT_DELAY_MS)
|
||||
Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." }
|
||||
delay(REBOOT_DELAY)
|
||||
|
||||
Logger.i { "BLE OTA: Connecting to $address using Kable..." }
|
||||
|
||||
|
|
@ -96,7 +99,7 @@ class BleOtaTransport(
|
|||
.launchIn(transportScope)
|
||||
|
||||
try {
|
||||
val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
|
||||
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}")
|
||||
|
|
@ -137,7 +140,7 @@ class BleOtaTransport(
|
|||
.launchIn(this)
|
||||
|
||||
// Allow time for the BLE subscription to be established before proceeding.
|
||||
delay(SUBSCRIPTION_SETTLE_MS)
|
||||
delay(SUBSCRIPTION_SETTLE)
|
||||
if (!subscribed.isCompleted) subscribed.complete(Unit)
|
||||
|
||||
subscribed.await()
|
||||
|
|
@ -156,7 +159,7 @@ class BleOtaTransport(
|
|||
var handshakeComplete = false
|
||||
var responsesReceived = 0
|
||||
while (!handshakeComplete) {
|
||||
val response = waitForResponse(ERASING_TIMEOUT_MS)
|
||||
val response = waitForResponse(ERASING_TIMEOUT)
|
||||
responsesReceived++
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ok -> {
|
||||
|
|
@ -203,7 +206,7 @@ class BleOtaTransport(
|
|||
|
||||
val nextSentBytes = sentBytes + currentChunkSize
|
||||
repeat(packetsSentForChunk) { i ->
|
||||
val response = waitForResponse(ACK_TIMEOUT_MS)
|
||||
val response = waitForResponse(ACK_TIMEOUT)
|
||||
val isLastPacketOfChunk = i == packetsSentForChunk - 1
|
||||
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
|
|
@ -229,7 +232,7 @@ class BleOtaTransport(
|
|||
onProgress(sentBytes.toFloat() / totalBytes)
|
||||
}
|
||||
|
||||
val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS)
|
||||
val finalResponse = waitForResponse(VERIFICATION_TIMEOUT)
|
||||
when (val parsed = OtaResponse.parse(finalResponse)) {
|
||||
is OtaResponse.Ok -> Unit
|
||||
is OtaResponse.Error -> {
|
||||
|
|
@ -274,21 +277,21 @@ class BleOtaTransport(
|
|||
return packetsSent
|
||||
}
|
||||
|
||||
private suspend fun waitForResponse(timeoutMs: Long): String = try {
|
||||
withTimeout(timeoutMs) { responseChannel.receive() }
|
||||
private suspend fun waitForResponse(timeout: Duration): String = try {
|
||||
withTimeout(timeout) { responseChannel.receive() }
|
||||
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms")
|
||||
throw OtaProtocolException.Timeout("Timeout waiting for response after $timeout")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONNECTION_TIMEOUT_MS = 15_000L
|
||||
private const val SUBSCRIPTION_SETTLE_MS = 500L
|
||||
private const val ERASING_TIMEOUT_MS = 60_000L
|
||||
private const val ACK_TIMEOUT_MS = 10_000L
|
||||
private const val VERIFICATION_TIMEOUT_MS = 10_000L
|
||||
private const val REBOOT_DELAY_MS = 5_000L
|
||||
private val CONNECTION_TIMEOUT = 15.seconds
|
||||
private val SUBSCRIPTION_SETTLE = 500.milliseconds
|
||||
private val ERASING_TIMEOUT = 60.seconds
|
||||
private val ACK_TIMEOUT = 10.seconds
|
||||
private val VERIFICATION_TIMEOUT = 10.seconds
|
||||
private val REBOOT_DELAY = 5.seconds
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 2_000L
|
||||
private val SCAN_RETRY_DELAY = 2.seconds
|
||||
const val RECOMMENDED_CHUNK_SIZE = 512
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import kotlin.time.Duration
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal const val DEFAULT_SCAN_RETRY_COUNT = 3
|
||||
internal const val DEFAULT_SCAN_RETRY_DELAY_MS = 2_000L
|
||||
internal val DEFAULT_SCAN_RETRY_DELAY: Duration = 2.seconds
|
||||
internal val DEFAULT_SCAN_TIMEOUT: Duration = 10.seconds
|
||||
|
||||
private const val MAC_PARTS_COUNT = 6
|
||||
|
|
@ -59,7 +59,7 @@ internal suspend fun scanForBleDevice(
|
|||
tag: String,
|
||||
serviceUuid: kotlin.uuid.Uuid,
|
||||
retryCount: Int = DEFAULT_SCAN_RETRY_COUNT,
|
||||
retryDelayMs: Long = DEFAULT_SCAN_RETRY_DELAY_MS,
|
||||
retryDelay: Duration = DEFAULT_SCAN_RETRY_DELAY,
|
||||
scanTimeout: Duration = DEFAULT_SCAN_TIMEOUT,
|
||||
predicate: (BleDevice) -> Boolean,
|
||||
): BleDevice? {
|
||||
|
|
@ -80,7 +80,7 @@ internal suspend fun scanForBleDevice(
|
|||
return device
|
||||
}
|
||||
Logger.w { "$tag: Target not in ${foundDevices.size} devices found" }
|
||||
if (attempt < retryCount - 1) delay(retryDelayMs)
|
||||
if (attempt < retryCount - 1) delay(retryDelay)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ import org.meshtastic.core.ble.BleWriteType
|
|||
import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH
|
||||
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
|
||||
import org.meshtastic.feature.firmware.ota.scanForBleDevice
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Kable-based transport for the Nordic Secure DFU (Secure DFU over BLE) protocol.
|
||||
|
|
@ -96,7 +99,7 @@ class SecureDfuTransport(
|
|||
?: throw DfuException.ConnectionFailed("Device $address not found for buttonless DFU trigger")
|
||||
|
||||
Logger.i { "DFU: Connecting to $address to trigger buttonless DFU..." }
|
||||
bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS)
|
||||
bleConnection.connectAndAwait(device, CONNECT_TIMEOUT)
|
||||
|
||||
bleConnection.profile(SecureDfuUuids.SERVICE) { service ->
|
||||
val buttonlessChar = service.characteristic(SecureDfuUuids.BUTTONLESS_NO_BONDS)
|
||||
|
|
@ -111,7 +114,7 @@ class SecureDfuTransport(
|
|||
.catch { e -> Logger.d(e) { "DFU: Buttonless indication stream ended (expected on disconnect)" } }
|
||||
.launchIn(this)
|
||||
|
||||
delay(SUBSCRIPTION_SETTLE_MS)
|
||||
delay(SUBSCRIPTION_SETTLE)
|
||||
|
||||
Logger.i { "DFU: Writing buttonless DFU trigger..." }
|
||||
service.write(buttonlessChar, byteArrayOf(0x01), BleWriteType.WITH_RESPONSE)
|
||||
|
|
@ -119,7 +122,7 @@ class SecureDfuTransport(
|
|||
// Wait for the indication response (0x20-01-STATUS). The device may disconnect before we receive it —
|
||||
// that's expected and treated as success, matching the Nordic DFU library's behavior.
|
||||
try {
|
||||
withTimeout(BUTTONLESS_RESPONSE_TIMEOUT_MS) {
|
||||
withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) {
|
||||
val response = indicationChannel.receive()
|
||||
if (response.size >= 3 && response[0] == BUTTONLESS_RESPONSE_CODE && response[2] != 0x01.toByte()) {
|
||||
Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" }
|
||||
|
|
@ -162,7 +165,7 @@ class SecureDfuTransport(
|
|||
|
||||
bleConnection.connectionState.onEach { Logger.d { "DFU: Connection state → $it" } }.launchIn(transportScope)
|
||||
|
||||
val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS)
|
||||
val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT)
|
||||
if (connected is BleConnectionState.Disconnected) {
|
||||
throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}")
|
||||
}
|
||||
|
|
@ -188,7 +191,7 @@ class SecureDfuTransport(
|
|||
}
|
||||
.launchIn(this)
|
||||
|
||||
delay(SUBSCRIPTION_SETTLE_MS)
|
||||
delay(SUBSCRIPTION_SETTLE)
|
||||
if (!subscribed.isCompleted) subscribed.complete(Unit)
|
||||
subscribed.await()
|
||||
|
||||
|
|
@ -286,7 +289,7 @@ class SecureDfuTransport(
|
|||
} catch (e: Throwable) {
|
||||
lastError = e
|
||||
Logger.w(e) { "DFU: Object transfer failed (attempt ${attempt + 1}/$OBJECT_RETRY_COUNT): ${e.message}" }
|
||||
if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY_MS)
|
||||
if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
throw lastError ?: DfuException.TransferFailed("Object transfer failed after $OBJECT_RETRY_COUNT attempts")
|
||||
|
|
@ -347,7 +350,7 @@ class SecureDfuTransport(
|
|||
// First-chunk delay: some older bootloaders need time to prepare flash after Create.
|
||||
// The Nordic DFU library uses 400ms for the first chunk.
|
||||
if (isFirstChunk) {
|
||||
delay(FIRST_CHUNK_DELAY_MS)
|
||||
delay(FIRST_CHUNK_DELAY)
|
||||
isFirstChunk = false
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +402,7 @@ class SecureDfuTransport(
|
|||
} catch (e: DfuException.ProtocolError) {
|
||||
if (e.resultCode == DfuResultCode.INVALID_OBJECT && offset + objectSize >= totalBytes) {
|
||||
Logger.w { "DFU: Execute returned INVALID_OBJECT on final object, retrying once..." }
|
||||
delay(RETRY_DELAY_MS)
|
||||
delay(RETRY_DELAY)
|
||||
sendExecute()
|
||||
} else {
|
||||
throw e
|
||||
|
|
@ -440,7 +443,7 @@ class SecureDfuTransport(
|
|||
// Wait for the device's PRN receipt notification, then validate CRC.
|
||||
// Skip the wait on the last packet — the final CALCULATE_CHECKSUM covers it.
|
||||
if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) {
|
||||
val response = awaitNotification(COMMAND_TIMEOUT_MS)
|
||||
val response = awaitNotification(COMMAND_TIMEOUT)
|
||||
if (response is DfuResponse.ChecksumResult) {
|
||||
val expectedCrc = DfuCrc32.calculate(data, length = pos)
|
||||
if (response.offset != pos || response.crc32 != expectedCrc) {
|
||||
|
|
@ -459,7 +462,7 @@ class SecureDfuTransport(
|
|||
val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT)
|
||||
service.write(controlChar, payload, BleWriteType.WITH_RESPONSE)
|
||||
}
|
||||
return awaitNotification(COMMAND_TIMEOUT_MS)
|
||||
return awaitNotification(COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
private suspend fun setPrn(value: Int) {
|
||||
|
|
@ -506,13 +509,13 @@ class SecureDfuTransport(
|
|||
Logger.d { "DFU: Object executed." }
|
||||
}
|
||||
|
||||
private suspend fun awaitNotification(timeoutMs: Long): DfuResponse = try {
|
||||
withTimeout(timeoutMs) {
|
||||
private suspend fun awaitNotification(timeout: Duration): DfuResponse = try {
|
||||
withTimeout(timeout) {
|
||||
val bytes = notificationChannel.receive()
|
||||
DfuResponse.parse(bytes).also { Logger.d { "DFU: Notification → $it" } }
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw DfuException.Timeout("No response from Control Point after ${timeoutMs}ms")
|
||||
throw DfuException.Timeout("No response from Control Point after $timeout")
|
||||
}
|
||||
|
||||
private fun DfuResponse.requireSuccess(expectedOpcode: Byte) {
|
||||
|
|
@ -541,7 +544,7 @@ class SecureDfuTransport(
|
|||
tag = "DFU",
|
||||
serviceUuid = SecureDfuUuids.SERVICE,
|
||||
retryCount = SCAN_RETRY_COUNT,
|
||||
retryDelayMs = SCAN_RETRY_DELAY_MS,
|
||||
retryDelay = SCAN_RETRY_DELAY,
|
||||
predicate = predicate,
|
||||
)
|
||||
|
||||
|
|
@ -550,14 +553,14 @@ class SecureDfuTransport(
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
private const val CONNECT_TIMEOUT_MS = 15_000L
|
||||
private const val COMMAND_TIMEOUT_MS = 30_000L
|
||||
private const val SUBSCRIPTION_SETTLE_MS = 500L
|
||||
private const val BUTTONLESS_RESPONSE_TIMEOUT_MS = 3_000L
|
||||
private val CONNECT_TIMEOUT = 15.seconds
|
||||
private val COMMAND_TIMEOUT = 30.seconds
|
||||
private val SUBSCRIPTION_SETTLE = 500.milliseconds
|
||||
private val BUTTONLESS_RESPONSE_TIMEOUT = 3.seconds
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 2_000L
|
||||
private const val RETRY_DELAY_MS = 2_000L
|
||||
private const val FIRST_CHUNK_DELAY_MS = 400L
|
||||
private val SCAN_RETRY_DELAY = 2.seconds
|
||||
private val RETRY_DELAY = 2.seconds
|
||||
private val FIRST_CHUNK_DELAY = 400.milliseconds
|
||||
|
||||
/** Response code prefix for Buttonless DFU indications (0x20 = response). */
|
||||
private const val BUTTONLESS_RESPONSE_CODE: Byte = 0x20
|
||||
|
|
|
|||
|
|
@ -614,8 +614,8 @@ class SecureDfuTransportTest {
|
|||
|
||||
override suspend fun connect(device: BleDevice) = delegate.connect(device)
|
||||
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long) =
|
||||
delegate.connectAndAwait(device, timeoutMs)
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeout: Duration) =
|
||||
delegate.connectAndAwait(device, timeout)
|
||||
|
||||
override suspend fun disconnect() = delegate.disconnect()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue