mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add ESP32 Unified OTA update support (#4095)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
parent
6b5dd24249
commit
2a60480bd9
40 changed files with 3410 additions and 717 deletions
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,22 +14,35 @@
|
|||
* 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.feature.firmware
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import no.nordicsemi.android.dfu.DfuBaseService
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.model.BuildConfig
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.firmware_update_channel_description
|
||||
import org.meshtastic.core.strings.firmware_update_channel_name
|
||||
|
||||
class FirmwareDfuService : DfuBaseService() {
|
||||
override fun onCreate() {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Using runBlocking here is acceptable as onCreate is a lifecycle method
|
||||
// and we need localized strings for the notification channel.
|
||||
val (channelName, channelDesc) =
|
||||
runBlocking {
|
||||
getString(Res.string.firmware_update_channel_name) to
|
||||
getString(Res.string.firmware_update_channel_description)
|
||||
}
|
||||
|
||||
val channel =
|
||||
NotificationChannel(NOTIFICATION_CHANNEL_DFU, "Firmware Update", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Firmware update status"
|
||||
NotificationChannel(NOTIFICATION_CHANNEL_DFU, channelName, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = channelDesc
|
||||
setShowBadge(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.feature.firmware
|
||||
|
||||
import android.content.Context
|
||||
|
|
@ -117,61 +116,97 @@ constructor(
|
|||
targetFile
|
||||
}
|
||||
|
||||
suspend fun extractFirmware(zipFile: File, hardware: DeviceHardware, fileExtension: String): File? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
if (target.isEmpty()) return@withContext null
|
||||
suspend fun extractFirmware(
|
||||
zipFile: File,
|
||||
hardware: DeviceHardware,
|
||||
fileExtension: String,
|
||||
preferredFilename: String? = null,
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
if (target.isEmpty() && preferredFilename == null) return@withContext null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
|
||||
val targetLowerCase = target.lowercase()
|
||||
val preferredFilenameLower = preferredFilename?.lowercase()
|
||||
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
|
||||
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
ZipInputStream(zipFile.inputStream()).use { zipInput ->
|
||||
ZipInputStream(zipFile.inputStream()).use { zipInput ->
|
||||
var entry = zipInput.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.lowercase()
|
||||
val entryFileName = File(name).name
|
||||
|
||||
val isMatch =
|
||||
if (preferredFilenameLower != null) {
|
||||
entryFileName == preferredFilenameLower
|
||||
} else {
|
||||
!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
val outFile = File(tempDir, entryFileName)
|
||||
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
|
||||
matchingEntries.add(entry to outFile)
|
||||
|
||||
if (preferredFilenameLower != null) {
|
||||
return@withContext outFile
|
||||
}
|
||||
}
|
||||
entry = zipInput.nextEntry
|
||||
}
|
||||
}
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second
|
||||
}
|
||||
|
||||
suspend fun extractFirmware(
|
||||
uri: Uri,
|
||||
hardware: DeviceHardware,
|
||||
fileExtension: String,
|
||||
preferredFilename: String? = null,
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
if (target.isEmpty() && preferredFilename == null) return@withContext null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
val preferredFilenameLower = preferredFilename?.lowercase()
|
||||
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
|
||||
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
|
||||
ZipInputStream(inputStream).use { zipInput ->
|
||||
var entry = zipInput.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.lowercase()
|
||||
if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)) {
|
||||
val outFile = File(tempDir, File(name).name)
|
||||
val entryFileName = File(name).name
|
||||
|
||||
val isMatch =
|
||||
if (preferredFilenameLower != null) {
|
||||
entryFileName == preferredFilenameLower
|
||||
} else {
|
||||
!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
val outFile = File(tempDir, entryFileName)
|
||||
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
|
||||
matchingEntries.add(entry to outFile)
|
||||
|
||||
if (preferredFilenameLower != null) {
|
||||
return@withContext outFile
|
||||
}
|
||||
}
|
||||
entry = zipInput.nextEntry
|
||||
}
|
||||
}
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second
|
||||
}
|
||||
|
||||
suspend fun extractFirmware(uri: Uri, hardware: DeviceHardware, fileExtension: String): File? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
if (target.isEmpty()) return@withContext null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
|
||||
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
|
||||
ZipInputStream(inputStream).use { zipInput ->
|
||||
var entry = zipInput.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.lowercase()
|
||||
if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)) {
|
||||
val outFile = File(tempDir, File(name).name)
|
||||
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
|
||||
matchingEntries.add(entry to outFile)
|
||||
}
|
||||
entry = zipInput.nextEntry
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Logger.w(e) { "Failed to extract firmware from URI" }
|
||||
return@withContext null
|
||||
}
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second
|
||||
} catch (e: IOException) {
|
||||
Logger.w(e) { "Failed to extract firmware from URI" }
|
||||
return@withContext null
|
||||
}
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second
|
||||
}
|
||||
|
||||
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
|
||||
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*")
|
||||
|
|
|
|||
|
|
@ -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.feature.firmware
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Retrieves firmware files, either by direct download or by extracting from a release asset. */
|
||||
class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFileHandler) {
|
||||
suspend fun retrieveOtaFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File? = retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = "-ota.zip",
|
||||
internalFileExtension = ".zip",
|
||||
)
|
||||
|
||||
suspend fun retrieveUsbFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File? = retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = ".uf2",
|
||||
internalFileExtension = ".uf2",
|
||||
)
|
||||
|
||||
suspend fun retrieveEsp32Firmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File? {
|
||||
if (hardware.supportsUnifiedOta) {
|
||||
val mcu = hardware.architecture.replace("-", "")
|
||||
val otaFilename = "mt-$mcu-ota.bin"
|
||||
retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = ".bin",
|
||||
internalFileExtension = ".bin",
|
||||
preferredFilename = otaFilename,
|
||||
)
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
return retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = ".bin",
|
||||
internalFileExtension = ".bin",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun retrieve(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
fileSuffix: String,
|
||||
internalFileExtension: String,
|
||||
preferredFilename: String? = null,
|
||||
): File? {
|
||||
val version = release.id.removePrefix("v")
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
val filename = preferredFilename ?: "firmware-$target-$version$fileSuffix"
|
||||
val directUrl =
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-$version/$filename"
|
||||
|
||||
if (fileHandler.checkUrlExists(directUrl)) {
|
||||
try {
|
||||
fileHandler.downloadFile(directUrl, filename, onProgress)?.let {
|
||||
return it
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "Direct download for $filename failed, falling back to release zip" }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to downloading the full release zip and extracting
|
||||
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
|
||||
val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress)
|
||||
return downloadedZip?.let {
|
||||
fileHandler.extractFirmware(it, hardware, internalFileExtension, preferredFilename)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
|
||||
val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
|
||||
for (arch in knownArchs) {
|
||||
if (url.contains(arch, ignoreCase = true)) {
|
||||
return url.replace(arch, targetArch.lowercase(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
|
||||
data class FirmwareUpdateActions(
|
||||
val onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
val onStartUpdate: () -> Unit,
|
||||
val onPickFile: () -> Unit,
|
||||
val onSaveFile: (String) -> Unit,
|
||||
val onRetry: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
val onDone: () -> Unit,
|
||||
val onDismissBootloaderWarning: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import android.net.Uri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
|
||||
/** Common interface for all firmware update handlers (BLE DFU, ESP32 OTA, USB). */
|
||||
interface FirmwareUpdateHandler {
|
||||
/**
|
||||
* Start the firmware update process.
|
||||
*
|
||||
* @param release The firmware release to install
|
||||
* @param hardware The target device hardware
|
||||
* @param target The target identifier (e.g., Bluetooth address, IP address, or empty for USB)
|
||||
* @param updateState Callback to report back state changes
|
||||
* @param firmwareUri Optional URI for a local firmware file (bypasses download)
|
||||
* @return The downloaded/extracted firmware file, or null if it was a local file or update finished
|
||||
*/
|
||||
suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
target: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri? = null,
|
||||
): File?
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.prefs.radio.isBle
|
||||
import org.meshtastic.core.prefs.radio.isSerial
|
||||
import org.meshtastic.core.prefs.radio.isTcp
|
||||
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Orchestrates the firmware update process by choosing the correct handler. */
|
||||
@Singleton
|
||||
class FirmwareUpdateManager
|
||||
@Inject
|
||||
constructor(
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val nordicDfuHandler: NordicDfuHandler,
|
||||
private val usbUpdateHandler: UsbUpdateHandler,
|
||||
private val esp32OtaUpdateHandler: Esp32OtaUpdateHandler,
|
||||
) {
|
||||
|
||||
/** Start the update process based on the current connection and hardware. */
|
||||
suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
address: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri? = null,
|
||||
): File? {
|
||||
val handler = getHandler(hardware)
|
||||
val target = getTarget(address)
|
||||
|
||||
return handler.startUpdate(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
target = target,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
)
|
||||
}
|
||||
|
||||
fun dfuProgressFlow(): Flow<DfuInternalState> = nordicDfuHandler.progressFlow()
|
||||
|
||||
private fun getHandler(hardware: DeviceHardware): FirmwareUpdateHandler = when {
|
||||
radioPrefs.isSerial() -> usbUpdateHandler
|
||||
radioPrefs.isBle() -> {
|
||||
if (isEsp32Architecture(hardware.architecture)) {
|
||||
esp32OtaUpdateHandler
|
||||
} else {
|
||||
nordicDfuHandler
|
||||
}
|
||||
}
|
||||
radioPrefs.isTcp() -> {
|
||||
if (isEsp32Architecture(hardware.architecture)) {
|
||||
esp32OtaUpdateHandler
|
||||
} else {
|
||||
// Should be handled/validated before calling startUpdate
|
||||
error("WiFi OTA only supported for ESP32 devices")
|
||||
}
|
||||
}
|
||||
else -> error("Unknown connection type for firmware update")
|
||||
}
|
||||
|
||||
private fun getTarget(address: String): String = when {
|
||||
radioPrefs.isSerial() -> ""
|
||||
radioPrefs.isBle() -> address
|
||||
radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr) ?: ""
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun isEsp32Architecture(architecture: String): Boolean = architecture.startsWith("esp32", ignoreCase = true)
|
||||
|
||||
private fun extractIpFromAddress(address: String?): String? =
|
||||
if (address != null && address.startsWith("t") && address.length > 1) {
|
||||
address.substring(1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("TooManyFunctions")
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
|
|
@ -23,13 +22,12 @@ package org.meshtastic.feature.firmware
|
|||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -47,14 +45,13 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Dangerous
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.rounded.Bluetooth
|
||||
import androidx.compose.material.icons.rounded.Usb
|
||||
import androidx.compose.material.icons.rounded.Wifi
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
|
|
@ -78,7 +75,6 @@ import androidx.compose.material3.TextButton
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -86,16 +82,20 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -103,6 +103,7 @@ import org.meshtastic.core.database.entity.FirmwareRelease
|
|||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.back
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.chirpy
|
||||
import org.meshtastic.core.strings.dont_show_again_for_device
|
||||
|
|
@ -117,44 +118,53 @@ import org.meshtastic.core.strings.firmware_update_disclaimer_title
|
|||
import org.meshtastic.core.strings.firmware_update_disconnect_warning
|
||||
import org.meshtastic.core.strings.firmware_update_do_not_close
|
||||
import org.meshtastic.core.strings.firmware_update_done
|
||||
import org.meshtastic.core.strings.firmware_update_downloading
|
||||
import org.meshtastic.core.strings.firmware_update_error
|
||||
import org.meshtastic.core.strings.firmware_update_hang_tight
|
||||
import org.meshtastic.core.strings.firmware_update_keep_device_close
|
||||
import org.meshtastic.core.strings.firmware_update_latest
|
||||
import org.meshtastic.core.strings.firmware_update_local_file
|
||||
import org.meshtastic.core.strings.firmware_update_method_detail
|
||||
import org.meshtastic.core.strings.firmware_update_rak4631_bootloader_hint
|
||||
import org.meshtastic.core.strings.firmware_update_release_notes
|
||||
import org.meshtastic.core.strings.firmware_update_retry
|
||||
import org.meshtastic.core.strings.firmware_update_save_dfu_file
|
||||
import org.meshtastic.core.strings.firmware_update_select_file
|
||||
import org.meshtastic.core.strings.firmware_update_source_local
|
||||
import org.meshtastic.core.strings.firmware_update_stable
|
||||
import org.meshtastic.core.strings.firmware_update_success
|
||||
import org.meshtastic.core.strings.firmware_update_taking_a_while
|
||||
import org.meshtastic.core.strings.firmware_update_target
|
||||
import org.meshtastic.core.strings.firmware_update_title
|
||||
import org.meshtastic.core.strings.firmware_update_unknown_release
|
||||
import org.meshtastic.core.strings.firmware_update_usb_bootloader_warning
|
||||
import org.meshtastic.core.strings.firmware_update_usb_instruction_text
|
||||
import org.meshtastic.core.strings.firmware_update_usb_instruction_title
|
||||
import org.meshtastic.core.strings.firmware_update_verification_failed
|
||||
import org.meshtastic.core.strings.firmware_update_verifying
|
||||
import org.meshtastic.core.strings.firmware_update_waiting_reconnect
|
||||
import org.meshtastic.core.strings.i_know_what_i_m_doing
|
||||
import org.meshtastic.core.strings.learn_more
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.save
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private const val CYCLE_DELAY_MS = 4500L
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun FirmwareUpdateScreen(
|
||||
navController: NavController,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: FirmwareUpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val selectedReleaseType by viewModel.selectedReleaseType.collectAsState()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selectedReleaseType by viewModel.selectedReleaseType.collectAsStateWithLifecycle()
|
||||
val deviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle()
|
||||
val currentVersion by viewModel.currentFirmwareVersion.collectAsStateWithLifecycle()
|
||||
val selectedRelease by viewModel.selectedRelease.collectAsStateWithLifecycle()
|
||||
|
||||
val getZipFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri?.let { viewModel.startUpdateFromFile(it) }
|
||||
}
|
||||
val getUf2FileLauncher =
|
||||
var showExitConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
val getFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri?.let { viewModel.startUpdateFromFile(it) }
|
||||
}
|
||||
|
|
@ -166,30 +176,67 @@ fun FirmwareUpdateScreen(
|
|||
uri?.let { viewModel.saveDfuFile(it) }
|
||||
}
|
||||
|
||||
val shouldKeepScreenOn = shouldKeepFirmwareScreenOn(state)
|
||||
val actions =
|
||||
remember(viewModel, navController, state) {
|
||||
FirmwareUpdateActions(
|
||||
onReleaseTypeSelect = viewModel::setReleaseType,
|
||||
onStartUpdate = viewModel::startUpdate,
|
||||
onPickFile = {
|
||||
if (state is FirmwareUpdateState.Ready) {
|
||||
val readyState = state as FirmwareUpdateState.Ready
|
||||
if (
|
||||
readyState.updateMethod is FirmwareUpdateMethod.Ble ||
|
||||
readyState.updateMethod is FirmwareUpdateMethod.Wifi
|
||||
) {
|
||||
getFileLauncher.launch("*/*")
|
||||
} else if (readyState.updateMethod is FirmwareUpdateMethod.Usb) {
|
||||
getFileLauncher.launch("*/*")
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveFile = { fileName -> saveFileLauncher.launch(fileName) },
|
||||
onRetry = viewModel::checkForUpdates,
|
||||
onCancel = { showExitConfirmation = true },
|
||||
onDone = { navController.navigateUp() },
|
||||
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
|
||||
)
|
||||
}
|
||||
|
||||
KeepScreenOn(shouldKeepScreenOn)
|
||||
KeepScreenOn(shouldKeepFirmwareScreenOn(state))
|
||||
|
||||
androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true }
|
||||
|
||||
if (showExitConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitConfirmation = false },
|
||||
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
|
||||
text = { Text(stringResource(Res.string.firmware_update_disconnect_warning)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showExitConfirmation = false
|
||||
viewModel.cancelUpdate()
|
||||
navController.navigateUp()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_retry)) // Use "Cancel & Exit" if available
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitConfirmation = false }) { Text(stringResource(Res.string.back)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FirmwareUpdateScaffold(
|
||||
modifier = modifier,
|
||||
navController = navController,
|
||||
state = state,
|
||||
selectedReleaseType = selectedReleaseType,
|
||||
onReleaseTypeSelect = viewModel::setReleaseType,
|
||||
onStartUpdate = viewModel::startUpdate,
|
||||
onPickFile = {
|
||||
if (state is FirmwareUpdateState.Ready) {
|
||||
if ((state as FirmwareUpdateState.Ready).updateMethod is FirmwareUpdateMethod.Ble) {
|
||||
getZipFileLauncher.launch("application/zip")
|
||||
} else if ((state as FirmwareUpdateState.Ready).updateMethod is FirmwareUpdateMethod.Usb) {
|
||||
getUf2FileLauncher.launch("*/*")
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveFile = { fileName -> saveFileLauncher.launch(fileName) },
|
||||
onRetry = viewModel::checkForUpdates,
|
||||
onDone = { navController.navigateUp() },
|
||||
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
|
||||
actions = actions,
|
||||
deviceHardware = deviceHardware,
|
||||
currentVersion = currentVersion,
|
||||
selectedRelease = selectedRelease,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -198,13 +245,10 @@ private fun FirmwareUpdateScaffold(
|
|||
navController: NavController,
|
||||
state: FirmwareUpdateState,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
onStartUpdate: () -> Unit,
|
||||
onPickFile: () -> Unit,
|
||||
onSaveFile: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
onDismissBootloaderWarning: () -> Unit,
|
||||
actions: FirmwareUpdateActions,
|
||||
deviceHardware: DeviceHardware?,
|
||||
currentVersion: String?,
|
||||
selectedRelease: FirmwareRelease?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -214,24 +258,46 @@ private fun FirmwareUpdateScaffold(
|
|||
title = { Text(stringResource(Res.string.firmware_update_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
FirmwareUpdateContent(
|
||||
state = state,
|
||||
selectedReleaseType = selectedReleaseType,
|
||||
onReleaseTypeSelect = onReleaseTypeSelect,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onPickFile = onPickFile,
|
||||
onSaveFile = onSaveFile,
|
||||
onRetry = onRetry,
|
||||
onDone = onDone,
|
||||
onDismissBootloaderWarning = onDismissBootloaderWarning,
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.animateContentSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (deviceHardware != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
AnimatedVisibility(
|
||||
visible =
|
||||
state is FirmwareUpdateState.Ready ||
|
||||
state is FirmwareUpdateState.Idle ||
|
||||
state is FirmwareUpdateState.Checking,
|
||||
) {
|
||||
Column {
|
||||
ReleaseTypeSelector(selectedReleaseType, actions.onReleaseTypeSelect)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
DeviceInfoCard(
|
||||
deviceHardware = deviceHardware,
|
||||
release = selectedRelease,
|
||||
currentFirmwareVersion = currentVersion,
|
||||
selectedReleaseType = selectedReleaseType,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.TopCenter) {
|
||||
FirmwareUpdateContent(state = state, selectedReleaseType = selectedReleaseType, actions = actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -240,6 +306,7 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh
|
|||
is FirmwareUpdateState.Downloading,
|
||||
is FirmwareUpdateState.Processing,
|
||||
is FirmwareUpdateState.Updating,
|
||||
is FirmwareUpdateState.Verifying,
|
||||
-> true
|
||||
|
||||
else -> false
|
||||
|
|
@ -249,25 +316,12 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh
|
|||
private fun FirmwareUpdateContent(
|
||||
state: FirmwareUpdateState,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
onStartUpdate: () -> Unit,
|
||||
onPickFile: () -> Unit,
|
||||
onSaveFile: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
onDismissBootloaderWarning: () -> Unit,
|
||||
actions: FirmwareUpdateActions,
|
||||
) {
|
||||
val modifier =
|
||||
if (state is FirmwareUpdateState.Ready) {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp)
|
||||
} else {
|
||||
Modifier.padding(24.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
content = {
|
||||
when (state) {
|
||||
is FirmwareUpdateState.Idle,
|
||||
|
|
@ -275,47 +329,59 @@ private fun FirmwareUpdateContent(
|
|||
-> CheckingState()
|
||||
|
||||
is FirmwareUpdateState.Ready ->
|
||||
ReadyState(
|
||||
state = state,
|
||||
selectedReleaseType = selectedReleaseType,
|
||||
onReleaseTypeSelect = onReleaseTypeSelect,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onPickFile = onPickFile,
|
||||
onDismissBootloaderWarning = onDismissBootloaderWarning,
|
||||
)
|
||||
ReadyState(state = state, selectedReleaseType = selectedReleaseType, actions = actions)
|
||||
|
||||
is FirmwareUpdateState.Downloading -> DownloadingState(state)
|
||||
is FirmwareUpdateState.Processing -> ProcessingState(state.message)
|
||||
is FirmwareUpdateState.Updating -> UpdatingState(state)
|
||||
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = onRetry)
|
||||
is FirmwareUpdateState.Success -> SuccessState(onDone = onDone)
|
||||
is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, onSaveFile)
|
||||
is FirmwareUpdateState.Downloading ->
|
||||
ProgressContent(state.progressState, onCancel = actions.onCancel, isDownloading = true)
|
||||
|
||||
is FirmwareUpdateState.Processing -> ProgressContent(state.progressState, onCancel = actions.onCancel)
|
||||
|
||||
is FirmwareUpdateState.Updating ->
|
||||
ProgressContent(state.progressState, onCancel = actions.onCancel, isUpdating = true)
|
||||
|
||||
is FirmwareUpdateState.Verifying -> VerifyingState()
|
||||
is FirmwareUpdateState.VerificationFailed ->
|
||||
VerificationFailedState(onRetry = actions.onStartUpdate, onIgnore = actions.onDone)
|
||||
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry)
|
||||
is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone)
|
||||
is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.CheckingState() {
|
||||
private fun VerifyingState() {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(stringResource(Res.string.firmware_update_verifying), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_waiting_reconnect),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckingState() {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(stringResource(Res.string.firmware_update_checking), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun ColumnScope.ReadyState(
|
||||
private fun ReadyState(
|
||||
state: FirmwareUpdateState.Ready,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
onStartUpdate: () -> Unit,
|
||||
onPickFile: () -> Unit,
|
||||
onDismissBootloaderWarning: () -> Unit,
|
||||
actions: FirmwareUpdateActions,
|
||||
) {
|
||||
var showDisclaimer by remember { mutableStateOf(false) }
|
||||
var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val device = state.deviceHardware
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
if (showDisclaimer) {
|
||||
DisclaimerDialog(
|
||||
|
|
@ -323,26 +389,38 @@ private fun ColumnScope.ReadyState(
|
|||
onDismissRequest = { showDisclaimer = false },
|
||||
onConfirm = {
|
||||
showDisclaimer = false
|
||||
pendingAction?.invoke()
|
||||
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
|
||||
actions.onPickFile()
|
||||
} else {
|
||||
actions.onStartUpdate()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
DeviceInfoCard(device, state.release, state.currentFirmwareVersion)
|
||||
|
||||
if (state.showBootloaderWarning) {
|
||||
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = actions.onDismissBootloaderWarning)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = onDismissBootloaderWarning)
|
||||
}
|
||||
|
||||
if (state.release != null) {
|
||||
ReleaseTypeSelector(selectedReleaseType, onReleaseTypeSelect)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ReleaseNotesCard(state.release.releaseNotes)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
|
||||
Button(
|
||||
onClick = {
|
||||
pendingAction = onStartUpdate
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showDisclaimer = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_select_file))
|
||||
}
|
||||
} else if (state.release != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showDisclaimer = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
|
|
@ -352,6 +430,7 @@ private fun ColumnScope.ReadyState(
|
|||
when (state.updateMethod) {
|
||||
FirmwareUpdateMethod.Ble -> Icons.Rounded.Bluetooth
|
||||
FirmwareUpdateMethod.Usb -> Icons.Rounded.Usb
|
||||
FirmwareUpdateMethod.Wifi -> Icons.Rounded.Wifi
|
||||
else -> Icons.Default.SystemUpdate
|
||||
},
|
||||
contentDescription = null,
|
||||
|
|
@ -364,19 +443,8 @@ private fun ColumnScope.ReadyState(
|
|||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
pendingAction = onPickFile
|
||||
showDisclaimer = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_select_file))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ReleaseNotesCard(state.release.releaseNotes)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +454,7 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques
|
|||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(modifier = Modifier.animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(Res.string.firmware_update_disclaimer_text))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -404,7 +472,7 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques
|
|||
)
|
||||
}
|
||||
if (updateMethod is FirmwareUpdateMethod.Ble) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ChirpyCard()
|
||||
}
|
||||
}
|
||||
|
|
@ -416,8 +484,11 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques
|
|||
|
||||
@Composable
|
||||
private fun ChirpyCard() {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
|
|
@ -428,6 +499,7 @@ private fun ChirpyCard() {
|
|||
model =
|
||||
ImageRequest.Builder(LocalContext.current)
|
||||
.data(org.meshtastic.core.ui.R.drawable.chirpy)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = stringResource(Res.string.chirpy),
|
||||
|
|
@ -437,6 +509,7 @@ private fun ChirpyCard() {
|
|||
Text(
|
||||
text = stringResource(Res.string.firmware_update_disclaimer_chirpy_says),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -446,8 +519,9 @@ private fun ChirpyCard() {
|
|||
private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
|
||||
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
|
||||
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
|
||||
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = deviceHardware.displayName,
|
||||
modifier = modifier,
|
||||
|
|
@ -456,32 +530,17 @@ private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifi
|
|||
|
||||
@Composable
|
||||
private fun ReleaseNotesCard(releaseNotes: String) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = "Release Notes", style = MaterialTheme.typography.titleMedium)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Markdown(content = releaseNotes, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_update_release_notes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Markdown(content = releaseNotes, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,11 +550,12 @@ private fun DeviceInfoCard(
|
|||
deviceHardware: DeviceHardware,
|
||||
release: FirmwareRelease?,
|
||||
currentFirmwareVersion: String? = null,
|
||||
selectedReleaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
|
||||
) {
|
||||
val target = deviceHardware.hwModelSlug.ifEmpty { deviceHardware.platformioTarget }
|
||||
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
|
|
@ -514,22 +574,28 @@ private fun DeviceInfoCard(
|
|||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Target: $target",
|
||||
stringResource(Res.string.firmware_update_target, target),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val currentVersion =
|
||||
val currentVersionString =
|
||||
stringResource(
|
||||
Res.string.firmware_update_currently_installed,
|
||||
currentFirmwareVersion ?: stringResource(Res.string.firmware_update_unknown_release),
|
||||
)
|
||||
Text(modifier = Modifier.fillMaxWidth(), text = currentVersion)
|
||||
Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val releaseVersion = release?.title ?: stringResource(Res.string.firmware_update_unknown_release)
|
||||
val (label, version) =
|
||||
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
|
||||
stringResource(Res.string.firmware_update_source_local) to ""
|
||||
} else {
|
||||
val releaseVersion = release?.title ?: stringResource(Res.string.firmware_update_unknown_release)
|
||||
stringResource(Res.string.firmware_update_latest, "") to releaseVersion
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(Res.string.firmware_update_latest, releaseVersion),
|
||||
text = "$label$version",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
|
@ -540,7 +606,7 @@ private fun DeviceInfoCard(
|
|||
@Composable
|
||||
private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||
colors =
|
||||
CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
|
|
@ -607,65 +673,86 @@ private fun ReleaseTypeSelector(
|
|||
SegmentedButton(
|
||||
selected = selectedReleaseType == FirmwareReleaseType.STABLE,
|
||||
onClick = { onReleaseTypeSelect(FirmwareReleaseType.STABLE) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3),
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_stable))
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = selectedReleaseType == FirmwareReleaseType.ALPHA,
|
||||
onClick = { onReleaseTypeSelect(FirmwareReleaseType.ALPHA) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3),
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_alpha))
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = selectedReleaseType == FirmwareReleaseType.LOCAL,
|
||||
onClick = { onReleaseTypeSelect(FirmwareReleaseType.LOCAL) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3),
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_local_file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun ColumnScope.DownloadingState(state: FirmwareUpdateState.Downloading) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_downloading, (state.progress * 100).toInt()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LinearWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ProcessingState(message: String) {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(message, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.UpdatingState(state: FirmwareUpdateState.Updating) {
|
||||
CircularWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(state.message, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.AwaitingFileSaveState(
|
||||
state: FirmwareUpdateState.AwaitingFileSave,
|
||||
onSaveFile: (String) -> Unit,
|
||||
private fun ProgressContent(
|
||||
progressState: ProgressState,
|
||||
onCancel: () -> Unit,
|
||||
isDownloading: Boolean = false,
|
||||
isUpdating: Boolean = false,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
if (isDownloading) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
CircularWavyProgressIndicator(
|
||||
progress = { if (isUpdating) progressState.progress else 1f },
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(progressState.message, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||
|
||||
val details = progressState.details
|
||||
if (details != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = details,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (isDownloading || isUpdating) {
|
||||
LinearWavyProgressIndicator(
|
||||
progress = { progressState.progress },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, onSaveFile: (String) -> Unit) {
|
||||
var showDialog by remember { mutableStateOf(true) }
|
||||
|
||||
if (showDialog) {
|
||||
|
|
@ -700,8 +787,6 @@ private fun ColumnScope.AwaitingFileSaveState(
|
|||
}
|
||||
}
|
||||
|
||||
private const val CYCLE_DELAY = 4000L
|
||||
|
||||
@Composable
|
||||
private fun CyclingMessages() {
|
||||
val messages =
|
||||
|
|
@ -716,23 +801,48 @@ private fun CyclingMessages() {
|
|||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(CYCLE_DELAY)
|
||||
delay(CYCLE_DELAY_MS)
|
||||
currentMessageIndex = (currentMessageIndex + 1) % messages.size
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(targetState = messages[currentMessageIndex], label = "CyclingMessage") { message ->
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
messages[currentMessageIndex],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationFailedState(onRetry: () -> Unit, onIgnore: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_verification_failed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_retry))
|
||||
}
|
||||
Button(onClick = onIgnore) { Text(stringResource(Res.string.firmware_update_done)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ErrorState(error: String, onRetry: () -> Unit) {
|
||||
private fun ErrorState(error: String, onRetry: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Default.Dangerous,
|
||||
contentDescription = null,
|
||||
|
|
@ -755,23 +865,28 @@ private fun ColumnScope.ErrorState(error: String, onRetry: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SuccessState(onDone: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_success),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text(stringResource(Res.string.firmware_update_done))
|
||||
private fun SuccessState(onDone: () -> Unit) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(100.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_success),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text(stringResource(Res.string.firmware_update_done))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.feature.firmware
|
||||
|
||||
import android.net.Uri
|
||||
|
|
@ -22,6 +21,15 @@ import org.meshtastic.core.database.entity.FirmwareRelease
|
|||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Represents the progress of a long-running firmware update task.
|
||||
*
|
||||
* @property message A high-level status message (e.g., "Downloading...").
|
||||
* @property progress A value between 0.0 and 1.0 representing completion percentage.
|
||||
* @property details Optional high-frequency detail text (e.g., "1.2 MiB/s, 45%").
|
||||
*/
|
||||
data class ProgressState(val message: String = "", val progress: Float = 0f, val details: String? = null)
|
||||
|
||||
sealed interface FirmwareUpdateState {
|
||||
data object Idle : FirmwareUpdateState
|
||||
|
||||
|
|
@ -36,11 +44,15 @@ sealed interface FirmwareUpdateState {
|
|||
val currentFirmwareVersion: String? = null,
|
||||
) : FirmwareUpdateState
|
||||
|
||||
data class Downloading(val progress: Float) : FirmwareUpdateState
|
||||
data class Downloading(val progressState: ProgressState) : FirmwareUpdateState
|
||||
|
||||
data class Processing(val message: String) : FirmwareUpdateState
|
||||
data class Processing(val progressState: ProgressState) : FirmwareUpdateState
|
||||
|
||||
data class Updating(val progress: Float, val message: String) : FirmwareUpdateState
|
||||
data class Updating(val progressState: ProgressState) : FirmwareUpdateState
|
||||
|
||||
data object Verifying : FirmwareUpdateState
|
||||
|
||||
data object VerificationFailed : FirmwareUpdateState
|
||||
|
||||
data class Error(val error: String) : FirmwareUpdateState
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,37 +14,27 @@
|
|||
* 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.feature.firmware
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import no.nordicsemi.android.dfu.DfuProgressListenerAdapter
|
||||
import no.nordicsemi.android.dfu.DfuServiceListenerHelper
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
|
|
@ -57,21 +47,30 @@ import org.meshtastic.core.model.DeviceHardware
|
|||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.prefs.radio.isBle
|
||||
import org.meshtastic.core.prefs.radio.isSerial
|
||||
import org.meshtastic.core.prefs.radio.isTcp
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.firmware_update_battery_low
|
||||
import org.meshtastic.core.strings.firmware_update_copying
|
||||
import org.meshtastic.core.strings.firmware_update_dfu_aborted
|
||||
import org.meshtastic.core.strings.firmware_update_dfu_error
|
||||
import org.meshtastic.core.strings.firmware_update_disconnecting
|
||||
import org.meshtastic.core.strings.firmware_update_enabling_dfu
|
||||
import org.meshtastic.core.strings.firmware_update_extracting
|
||||
import org.meshtastic.core.strings.firmware_update_failed
|
||||
import org.meshtastic.core.strings.firmware_update_flashing
|
||||
import org.meshtastic.core.strings.firmware_update_local_failed
|
||||
import org.meshtastic.core.strings.firmware_update_method_ble
|
||||
import org.meshtastic.core.strings.firmware_update_method_usb
|
||||
import org.meshtastic.core.strings.firmware_update_method_wifi
|
||||
import org.meshtastic.core.strings.firmware_update_no_device
|
||||
import org.meshtastic.core.strings.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.strings.firmware_update_rebooting
|
||||
import org.meshtastic.core.strings.firmware_update_node_info_missing
|
||||
import org.meshtastic.core.strings.firmware_update_starting_dfu
|
||||
import org.meshtastic.core.strings.firmware_update_starting_service
|
||||
import org.meshtastic.core.strings.firmware_update_unknown_error
|
||||
import org.meshtastic.core.strings.firmware_update_unknown_hardware
|
||||
import org.meshtastic.core.strings.firmware_update_updating
|
||||
import org.meshtastic.core.strings.firmware_update_validating
|
||||
import org.meshtastic.core.strings.unknown
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
|
@ -79,11 +78,16 @@ import javax.inject.Inject
|
|||
private const val DFU_RECONNECT_PREFIX = "x"
|
||||
private const val PERCENT_MAX_VALUE = 100f
|
||||
private const val DEVICE_DETACH_TIMEOUT = 30_000L
|
||||
private const val VERIFY_TIMEOUT = 60_000L
|
||||
private const val VERIFY_DELAY = 2000L
|
||||
private const val MIN_BATTERY_LEVEL = 10
|
||||
private const val KIB_DIVISOR = 1024f
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
|
||||
private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||
|
||||
@HiltViewModel
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
class FirmwareUpdateViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
|
|
@ -92,10 +96,9 @@ constructor(
|
|||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val bootloaderWarningDataSource: BootloaderWarningDataSource,
|
||||
private val otaUpdateHandler: OtaUpdateHandler,
|
||||
private val usbUpdateHandler: UsbUpdateHandler,
|
||||
private val firmwareUpdateManager: FirmwareUpdateManager,
|
||||
private val usbManager: UsbManager,
|
||||
private val fileHandler: FirmwareFileHandler,
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -105,8 +108,18 @@ constructor(
|
|||
private val _selectedReleaseType = MutableStateFlow(FirmwareReleaseType.STABLE)
|
||||
val selectedReleaseType: StateFlow<FirmwareReleaseType> = _selectedReleaseType.asStateFlow()
|
||||
|
||||
private val _selectedRelease = MutableStateFlow<FirmwareRelease?>(null)
|
||||
val selectedRelease: StateFlow<FirmwareRelease?> = _selectedRelease.asStateFlow()
|
||||
|
||||
private val _deviceHardware = MutableStateFlow<DeviceHardware?>(null)
|
||||
val deviceHardware = _deviceHardware.asStateFlow()
|
||||
|
||||
private val _currentFirmwareVersion = MutableStateFlow<String?>(null)
|
||||
val currentFirmwareVersion = _currentFirmwareVersion.asStateFlow()
|
||||
|
||||
private var updateJob: Job? = null
|
||||
private var tempFirmwareFile: File? = null
|
||||
private var originalDeviceAddress: String? = null
|
||||
|
||||
init {
|
||||
// Cleanup potential leftovers
|
||||
|
|
@ -127,6 +140,12 @@ constructor(
|
|||
checkForUpdates()
|
||||
}
|
||||
|
||||
fun cancelUpdate() {
|
||||
updateJob?.cancel()
|
||||
_state.value = FirmwareUpdateState.Idle
|
||||
checkForUpdates()
|
||||
}
|
||||
|
||||
fun checkForUpdates() {
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
|
|
@ -140,15 +159,26 @@ constructor(
|
|||
return@launch
|
||||
}
|
||||
getDeviceHardware(ourNode)?.let { deviceHardware ->
|
||||
firmwareReleaseRepository.getReleaseFlow(
|
||||
_selectedReleaseType.value,
|
||||
).collectLatest { release ->
|
||||
_deviceHardware.value = deviceHardware
|
||||
_currentFirmwareVersion.value = ourNode.metadata?.firmwareVersion
|
||||
|
||||
val releaseFlow =
|
||||
if (_selectedReleaseType.value == FirmwareReleaseType.LOCAL) {
|
||||
kotlinx.coroutines.flow.flowOf(null)
|
||||
} else {
|
||||
firmwareReleaseRepository.getReleaseFlow(_selectedReleaseType.value)
|
||||
}
|
||||
|
||||
releaseFlow.collectLatest { release ->
|
||||
_selectedRelease.value = release
|
||||
val dismissed = bootloaderWarningDataSource.isDismissed(address)
|
||||
val firmwareUpdateMethod =
|
||||
if (radioPrefs.isSerial()) {
|
||||
FirmwareUpdateMethod.Usb
|
||||
} else if (radioPrefs.isBle()) {
|
||||
FirmwareUpdateMethod.Ble
|
||||
} else if (radioPrefs.isTcp()) {
|
||||
FirmwareUpdateMethod.Wifi
|
||||
} else {
|
||||
FirmwareUpdateMethod.Unknown
|
||||
}
|
||||
|
|
@ -170,7 +200,8 @@ constructor(
|
|||
.onFailure { e ->
|
||||
if (e is CancellationException) throw e
|
||||
Logger.e(e) { "Error checking for updates" }
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: "Unknown error")
|
||||
val unknownError = getString(Res.string.firmware_update_unknown_error)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: unknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,34 +209,37 @@ constructor(
|
|||
fun startUpdate() {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
val release = currentState.release ?: return
|
||||
originalDeviceAddress = currentState.address
|
||||
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
if (radioPrefs.isSerial()) {
|
||||
tempFirmwareFile =
|
||||
usbUpdateHandler.startUpdate(
|
||||
release = release,
|
||||
hardware = currentState.deviceHardware,
|
||||
updateState = { _state.value = it },
|
||||
rebootingMsg = getString(Res.string.firmware_update_rebooting),
|
||||
)
|
||||
} else if (radioPrefs.isBle()) {
|
||||
tempFirmwareFile =
|
||||
otaUpdateHandler.startUpdate(
|
||||
release = release,
|
||||
hardware = currentState.deviceHardware,
|
||||
address = currentState.address,
|
||||
updateState = { _state.value = it },
|
||||
notFoundMsg =
|
||||
getString(
|
||||
Res.string.firmware_update_not_found_in_release,
|
||||
currentState.deviceHardware.displayName,
|
||||
),
|
||||
startingMsg = getString(Res.string.firmware_update_starting_service),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
if (checkBatteryLevel()) {
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
tempFirmwareFile =
|
||||
firmwareUpdateManager.startUpdate(
|
||||
release = release,
|
||||
hardware = currentState.deviceHardware,
|
||||
address = currentState.address,
|
||||
updateState = { _state.value = it },
|
||||
)
|
||||
|
||||
if (_state.value is FirmwareUpdateState.Success) {
|
||||
verifyUpdateResult(originalDeviceAddress)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Logger.i { "Firmware update cancelled" }
|
||||
_state.value = FirmwareUpdateState.Idle
|
||||
checkForUpdates()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failedMsg = getString(Res.string.firmware_update_failed)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: failedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDfuFile(uri: Uri) {
|
||||
|
|
@ -215,23 +249,26 @@ constructor(
|
|||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_copying))
|
||||
val copyingMsg = getString(Res.string.firmware_update_copying)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(copyingMsg))
|
||||
if (firmwareFile != null) {
|
||||
fileHandler.copyFileToUri(firmwareFile, uri)
|
||||
} else if (sourceUri != null) {
|
||||
fileHandler.copyUriToUri(sourceUri, uri)
|
||||
}
|
||||
|
||||
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_flashing))
|
||||
withTimeoutOrNull(DEVICE_DETACH_TIMEOUT) { waitForDeviceDetach(context).first() }
|
||||
val flashingMsg = getString(Res.string.firmware_update_flashing)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(flashingMsg))
|
||||
withTimeoutOrNull(DEVICE_DETACH_TIMEOUT) { usbManager.deviceDetachFlow().first() }
|
||||
?: Logger.w { "Timed out waiting for device to detach, assuming success" }
|
||||
|
||||
_state.value = FirmwareUpdateState.Success
|
||||
verifyUpdateResult(originalDeviceAddress)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Error saving DFU file" }
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: getString(Res.string.firmware_update_failed))
|
||||
val failedMsg = getString(Res.string.firmware_update_failed)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: failedMsg)
|
||||
} finally {
|
||||
cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
}
|
||||
|
|
@ -241,46 +278,45 @@ constructor(
|
|||
fun startUpdateFromFile(uri: Uri) {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) {
|
||||
viewModelScope.launch {
|
||||
val noDeviceMsg = getString(Res.string.firmware_update_no_device)
|
||||
_state.value = FirmwareUpdateState.Error(noDeviceMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
originalDeviceAddress = currentState.address
|
||||
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_extracting))
|
||||
val extractingMsg = getString(Res.string.firmware_update_extracting)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(extractingMsg))
|
||||
val extension = if (currentState.updateMethod is FirmwareUpdateMethod.Ble) ".zip" else ".uf2"
|
||||
val extractedFile = fileHandler.extractFirmware(uri, currentState.deviceHardware, extension)
|
||||
|
||||
tempFirmwareFile = extractedFile
|
||||
val firmwareUri = if (extractedFile != null) Uri.fromFile(extractedFile) else uri
|
||||
|
||||
if (currentState.updateMethod is FirmwareUpdateMethod.Ble) {
|
||||
otaUpdateHandler.startUpdate(
|
||||
tempFirmwareFile =
|
||||
firmwareUpdateManager.startUpdate(
|
||||
release =
|
||||
FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""),
|
||||
hardware = currentState.deviceHardware,
|
||||
address = currentState.address,
|
||||
updateState = { _state.value = it },
|
||||
notFoundMsg = "File not found",
|
||||
startingMsg = getString(Res.string.firmware_update_starting_service),
|
||||
firmwareUri = firmwareUri,
|
||||
)
|
||||
} else if (currentState.updateMethod is FirmwareUpdateMethod.Usb) {
|
||||
usbUpdateHandler.startUpdate(
|
||||
release =
|
||||
FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""),
|
||||
hardware = currentState.deviceHardware,
|
||||
updateState = { _state.value = it },
|
||||
rebootingMsg = getString(Res.string.firmware_update_rebooting),
|
||||
firmwareUri = firmwareUri,
|
||||
)
|
||||
|
||||
if (_state.value is FirmwareUpdateState.Success) {
|
||||
verifyUpdateResult(originalDeviceAddress)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Error starting update from file" }
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: "Local update failed")
|
||||
val failedMsg = getString(Res.string.firmware_update_local_failed)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: failedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -294,37 +330,131 @@ constructor(
|
|||
}
|
||||
|
||||
private suspend fun observeDfuProgress() {
|
||||
dfuProgressFlow(context).flowOn(Dispatchers.Main).collect { dfuState ->
|
||||
firmwareUpdateManager.dfuProgressFlow().flowOn(Dispatchers.Main).collect { dfuState ->
|
||||
when (dfuState) {
|
||||
is DfuInternalState.Progress -> {
|
||||
val msg = getString(Res.string.firmware_update_updating, "${dfuState.percent}")
|
||||
_state.value = FirmwareUpdateState.Updating(dfuState.percent / PERCENT_MAX_VALUE, msg)
|
||||
}
|
||||
is DfuInternalState.Progress -> handleDfuProgress(dfuState)
|
||||
|
||||
is DfuInternalState.Error -> {
|
||||
_state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}")
|
||||
val errorMsg = getString(Res.string.firmware_update_dfu_error, dfuState.message ?: "")
|
||||
_state.value = FirmwareUpdateState.Error(errorMsg)
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
}
|
||||
|
||||
is DfuInternalState.Completed -> {
|
||||
_state.value = FirmwareUpdateState.Success
|
||||
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}")
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
verifyUpdateResult(originalDeviceAddress)
|
||||
}
|
||||
|
||||
is DfuInternalState.Aborted -> {
|
||||
_state.value = FirmwareUpdateState.Error("DFU Aborted")
|
||||
val abortedMsg = getString(Res.string.firmware_update_dfu_aborted)
|
||||
_state.value = FirmwareUpdateState.Error(abortedMsg)
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
}
|
||||
|
||||
is DfuInternalState.Starting -> {
|
||||
val msg = getString(Res.string.firmware_update_starting_dfu)
|
||||
_state.value = FirmwareUpdateState.Processing(msg)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(msg))
|
||||
}
|
||||
|
||||
is DfuInternalState.EnablingDfuMode -> {
|
||||
val msg = getString(Res.string.firmware_update_enabling_dfu)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(msg))
|
||||
}
|
||||
|
||||
is DfuInternalState.Validating -> {
|
||||
val msg = getString(Res.string.firmware_update_validating)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(msg))
|
||||
}
|
||||
|
||||
is DfuInternalState.Disconnecting -> {
|
||||
val msg = getString(Res.string.firmware_update_disconnecting)
|
||||
_state.value = FirmwareUpdateState.Processing(ProgressState(msg))
|
||||
}
|
||||
|
||||
else -> {} // ignore connected/disconnected for UI noise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDfuProgress(dfuState: DfuInternalState.Progress) {
|
||||
val progress = dfuState.percent / PERCENT_MAX_VALUE
|
||||
val percentText = "${dfuState.percent}%"
|
||||
|
||||
// Nordic DFU speed is in Bytes/ms. Convert to KiB/s.
|
||||
val speedBytesPerSec = dfuState.speed * MILLIS_PER_SECOND
|
||||
val speedKib = speedBytesPerSec / KIB_DIVISOR
|
||||
|
||||
// Calculate ETA
|
||||
val totalBytes = tempFirmwareFile?.length() ?: 0L
|
||||
val etaText =
|
||||
if (totalBytes > 0 && speedBytesPerSec > 0 && dfuState.percent > 0) {
|
||||
val remainingBytes = totalBytes * (1f - progress)
|
||||
val etaSeconds = remainingBytes / speedBytesPerSec
|
||||
", ETA: ${etaSeconds.toInt()}s"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val partInfo =
|
||||
if (dfuState.partsTotal > 1) {
|
||||
" (Part ${dfuState.currentPart}/${dfuState.partsTotal})"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val metrics =
|
||||
if (dfuState.speed > 0) {
|
||||
String.format(java.util.Locale.US, "%.1f KiB/s%s%s", speedKib, etaText, partInfo)
|
||||
} else {
|
||||
partInfo
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val statusMsg =
|
||||
getString(Res.string.firmware_update_updating, "").replace(Regex(":?\\s*%1\\\$s%?"), "").trim()
|
||||
val details = "$percentText ($metrics)"
|
||||
_state.value = FirmwareUpdateState.Updating(ProgressState(statusMsg, progress, details))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verifyUpdateResult(address: String?) {
|
||||
_state.value = FirmwareUpdateState.Verifying
|
||||
|
||||
// Trigger a fresh connection attempt by MeshService
|
||||
address?.let { currentAddr ->
|
||||
Logger.i { "Post-update: Requesting MeshService to reconnect to $currentAddr" }
|
||||
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX$currentAddr")
|
||||
}
|
||||
|
||||
// Wait for device to reconnect and settle
|
||||
val result =
|
||||
withTimeoutOrNull(VERIFY_TIMEOUT) {
|
||||
// Wait for both Connected state and node info to be present
|
||||
serviceRepository.connectionState.first { it is ConnectionState.Connected }
|
||||
nodeRepository.ourNodeInfo.filterNotNull().first()
|
||||
delay(VERIFY_DELAY) // Extra buffer for initial config sync
|
||||
true
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
Logger.w { "Post-update verification timed out for $address" }
|
||||
_state.value = FirmwareUpdateState.VerificationFailed
|
||||
} else {
|
||||
_state.value = FirmwareUpdateState.Success
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkBatteryLevel(): Boolean {
|
||||
val node = nodeRepository.ourNodeInfo.value ?: return true
|
||||
val level = node.batteryLevel
|
||||
val isBatteryLow = level in 1..MIN_BATTERY_LEVEL
|
||||
|
||||
if (isBatteryLow) {
|
||||
val batteryLowMsg = getString(Res.string.firmware_update_battery_low, level)
|
||||
_state.value = FirmwareUpdateState.Error(batteryLowMsg)
|
||||
}
|
||||
return !isBatteryLow
|
||||
}
|
||||
|
||||
private suspend fun getDeviceHardware(ourNode: org.meshtastic.core.database.model.Node): DeviceHardware? {
|
||||
val hwModel = ourNode.user.hwModel?.number
|
||||
return if (hwModel != null) {
|
||||
|
|
@ -334,7 +464,8 @@ constructor(
|
|||
null
|
||||
}
|
||||
} else {
|
||||
_state.value = FirmwareUpdateState.Error("Node user information is missing.")
|
||||
val nodeInfoMissing = getString(Res.string.firmware_update_node_info_missing)
|
||||
_state.value = FirmwareUpdateState.Error(nodeInfoMissing)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -349,80 +480,13 @@ private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmware
|
|||
return null
|
||||
}
|
||||
|
||||
private fun waitForDeviceDetach(context: Context): Flow<Unit> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
|
||||
trySend(Unit).isSuccess
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
private sealed interface DfuInternalState {
|
||||
data class Starting(val address: String) : DfuInternalState
|
||||
|
||||
data class Progress(val address: String, val percent: Int) : DfuInternalState
|
||||
|
||||
data class Completed(val address: String) : DfuInternalState
|
||||
|
||||
data class Aborted(val address: String) : DfuInternalState
|
||||
|
||||
data class Error(val address: String, val message: String?) : DfuInternalState
|
||||
}
|
||||
|
||||
private fun isValidBluetoothAddress(address: String?): Boolean =
|
||||
address != null && BLUETOOTH_ADDRESS_REGEX.matches(address)
|
||||
|
||||
private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType): Flow<FirmwareRelease?> = when (type) {
|
||||
FirmwareReleaseType.STABLE -> stableRelease
|
||||
FirmwareReleaseType.ALPHA -> alphaRelease
|
||||
}
|
||||
|
||||
private fun dfuProgressFlow(context: Context): Flow<DfuInternalState> = callbackFlow {
|
||||
val listener =
|
||||
object : DfuProgressListenerAdapter() {
|
||||
override fun onDfuProcessStarting(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Starting(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onProgressChanged(
|
||||
deviceAddress: String,
|
||||
percent: Int,
|
||||
speed: Float,
|
||||
avgSpeed: Float,
|
||||
currentPart: Int,
|
||||
partsTotal: Int,
|
||||
) {
|
||||
trySend(DfuInternalState.Progress(deviceAddress, percent))
|
||||
}
|
||||
|
||||
override fun onDfuCompleted(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Completed(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDfuAborted(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Aborted(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onError(deviceAddress: String, error: Int, errorType: Int, message: String?) {
|
||||
trySend(DfuInternalState.Error(deviceAddress, message))
|
||||
}
|
||||
}
|
||||
|
||||
DfuServiceListenerHelper.registerProgressListener(context, listener)
|
||||
awaitClose { DfuServiceListenerHelper.unregisterProgressListener(context, listener) }
|
||||
FirmwareReleaseType.LOCAL -> kotlinx.coroutines.flow.flowOf(null)
|
||||
}
|
||||
|
||||
sealed class FirmwareUpdateMethod(val description: StringResource) {
|
||||
|
|
@ -430,5 +494,7 @@ sealed class FirmwareUpdateMethod(val description: StringResource) {
|
|||
|
||||
object Ble : FirmwareUpdateMethod(Res.string.firmware_update_method_ble)
|
||||
|
||||
object Wifi : FirmwareUpdateMethod(Res.string.firmware_update_method_wifi)
|
||||
|
||||
object Unknown : FirmwareUpdateMethod(Res.string.unknown)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import no.nordicsemi.android.dfu.DfuBaseService
|
||||
import no.nordicsemi.android.dfu.DfuLogListener
|
||||
import no.nordicsemi.android.dfu.DfuProgressListenerAdapter
|
||||
import no.nordicsemi.android.dfu.DfuServiceInitiator
|
||||
import no.nordicsemi.android.dfu.DfuServiceListenerHelper
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.strings.firmware_update_nordic_failed
|
||||
import org.meshtastic.core.strings.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.strings.firmware_update_starting_service
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SCAN_TIMEOUT = 5000L
|
||||
private const val PACKETS_BEFORE_PRN = 8
|
||||
private const val PERCENT_MAX = 100
|
||||
private const val PREPARE_DATA_DELAY = 400L
|
||||
|
||||
/** Handles Over-the-Air (OTA) firmware updates for nRF52-based devices using the Nordic DFU library. */
|
||||
class NordicDfuHandler
|
||||
@Inject
|
||||
constructor(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) : FirmwareUpdateHandler {
|
||||
|
||||
override suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
target: String, // Bluetooth address
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri?,
|
||||
): File? =
|
||||
try {
|
||||
val downloadingMsg =
|
||||
getString(Res.string.firmware_update_downloading_percent, 0)
|
||||
.replace(Regex(":?\\s*%1\\\$d%?"), "")
|
||||
.trim()
|
||||
|
||||
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
|
||||
|
||||
if (firmwareUri != null) {
|
||||
initiateDfu(target, hardware, firmwareUri, updateState)
|
||||
null
|
||||
} else {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
val errorMsg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName)
|
||||
updateState(FirmwareUpdateState.Error(errorMsg))
|
||||
null
|
||||
} else {
|
||||
initiateDfu(target, hardware, Uri.fromFile(firmwareFile), updateState)
|
||||
firmwareFile
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Nordic DFU Update failed" }
|
||||
val errorMsg = getString(Res.string.firmware_update_nordic_failed)
|
||||
updateState(FirmwareUpdateState.Error(e.message ?: errorMsg))
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun initiateDfu(
|
||||
address: String,
|
||||
deviceHardware: DeviceHardware,
|
||||
firmwareUri: Uri,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
) {
|
||||
val startingMsg = getString(Res.string.firmware_update_starting_service)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(startingMsg)))
|
||||
|
||||
// n = Nordic (Legacy prefix handling in mesh service)
|
||||
serviceRepository.meshService?.setDeviceAddress("n")
|
||||
|
||||
DfuServiceInitiator(address)
|
||||
.setDeviceName(deviceHardware.displayName)
|
||||
.setPrepareDataObjectDelay(PREPARE_DATA_DELAY)
|
||||
.setForceScanningForNewAddressInLegacyDfu(true)
|
||||
.setRestoreBond(true)
|
||||
.setForeground(true)
|
||||
.setKeepBond(true)
|
||||
.setForceDfu(false)
|
||||
.setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN)
|
||||
.setPacketsReceiptNotificationsEnabled(true)
|
||||
.setScanTimeout(SCAN_TIMEOUT)
|
||||
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
|
||||
.setZip(firmwareUri)
|
||||
.start(context, FirmwareDfuService::class.java)
|
||||
}
|
||||
|
||||
/** Observe DFU progress and events. */
|
||||
fun progressFlow(): Flow<DfuInternalState> = callbackFlow {
|
||||
val listener =
|
||||
object : DfuProgressListenerAdapter() {
|
||||
override fun onDeviceConnecting(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Connecting(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDeviceConnected(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Connected(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDfuProcessStarting(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Starting(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onEnablingDfuMode(deviceAddress: String) {
|
||||
trySend(DfuInternalState.EnablingDfuMode(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onProgressChanged(
|
||||
deviceAddress: String,
|
||||
percent: Int,
|
||||
speed: Float,
|
||||
avgSpeed: Float,
|
||||
currentPart: Int,
|
||||
partsTotal: Int,
|
||||
) {
|
||||
trySend(DfuInternalState.Progress(deviceAddress, percent, speed, avgSpeed, currentPart, partsTotal))
|
||||
}
|
||||
|
||||
override fun onFirmwareValidating(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Validating(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnecting(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Disconnecting(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Disconnected(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDfuCompleted(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Completed(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onDfuAborted(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Aborted(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onError(deviceAddress: String, error: Int, errorType: Int, message: String?) {
|
||||
trySend(DfuInternalState.Error(deviceAddress, message))
|
||||
}
|
||||
}
|
||||
|
||||
val logListener =
|
||||
object : DfuLogListener {
|
||||
override fun onLogEvent(deviceAddress: String, level: Int, message: String) {
|
||||
val severity =
|
||||
when (level) {
|
||||
DfuBaseService.LOG_LEVEL_DEBUG -> Severity.Debug
|
||||
DfuBaseService.LOG_LEVEL_INFO -> Severity.Info
|
||||
DfuBaseService.LOG_LEVEL_APPLICATION -> Severity.Info
|
||||
DfuBaseService.LOG_LEVEL_WARNING -> Severity.Warn
|
||||
DfuBaseService.LOG_LEVEL_ERROR -> Severity.Error
|
||||
else -> Severity.Verbose
|
||||
}
|
||||
Logger.log(severity, tag = "NordicDFU", null, "[$deviceAddress] $message")
|
||||
}
|
||||
}
|
||||
|
||||
DfuServiceListenerHelper.registerProgressListener(context, listener)
|
||||
DfuServiceListenerHelper.registerLogListener(context, logListener)
|
||||
|
||||
awaitClose {
|
||||
runCatching {
|
||||
DfuServiceListenerHelper.unregisterProgressListener(context, listener)
|
||||
DfuServiceListenerHelper.unregisterLogListener(context, logListener)
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to unregister DFU listeners" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface DfuInternalState {
|
||||
val address: String
|
||||
|
||||
data class Connecting(override val address: String) : DfuInternalState
|
||||
|
||||
data class Connected(override val address: String) : DfuInternalState
|
||||
|
||||
data class Starting(override val address: String) : DfuInternalState
|
||||
|
||||
data class EnablingDfuMode(override val address: String) : DfuInternalState
|
||||
|
||||
data class Progress(
|
||||
override val address: String,
|
||||
val percent: Int,
|
||||
val speed: Float,
|
||||
val avgSpeed: Float,
|
||||
val currentPart: Int,
|
||||
val partsTotal: Int,
|
||||
) : DfuInternalState
|
||||
|
||||
data class Validating(override val address: String) : DfuInternalState
|
||||
|
||||
data class Disconnecting(override val address: String) : DfuInternalState
|
||||
|
||||
data class Disconnected(override val address: String) : DfuInternalState
|
||||
|
||||
data class Completed(override val address: String) : DfuInternalState
|
||||
|
||||
data class Aborted(override val address: String) : DfuInternalState
|
||||
|
||||
data class Error(override val address: String, val message: String?) : DfuInternalState
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.feature.firmware
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import co.touchlab.kermit.Logger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import no.nordicsemi.android.dfu.DfuServiceInitiator
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SCAN_TIMEOUT = 2000L
|
||||
private const val PACKETS_BEFORE_PRN = 8
|
||||
private const val REBOOT_DELAY = 5000L
|
||||
|
||||
private const val DATA_OBJECT_DELAY = 400L
|
||||
|
||||
/** Retrieves firmware files, either by direct download or by extracting from a release asset. */
|
||||
class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFileHandler) {
|
||||
suspend fun retrieveOtaFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File? = retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = "-ota.zip",
|
||||
internalFileExtension = ".zip",
|
||||
)
|
||||
|
||||
suspend fun retrieveUsbFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File? = retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = ".uf2",
|
||||
internalFileExtension = ".uf2",
|
||||
)
|
||||
|
||||
private suspend fun retrieve(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
fileSuffix: String,
|
||||
internalFileExtension: String,
|
||||
): File? {
|
||||
val version = release.id.removePrefix("v")
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
val filename = "firmware-$target-$version$fileSuffix"
|
||||
val directUrl =
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-$version/$filename"
|
||||
|
||||
if (fileHandler.checkUrlExists(directUrl)) {
|
||||
try {
|
||||
fileHandler.downloadFile(directUrl, filename, onProgress)?.let {
|
||||
return it
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Direct download for $filename failed, falling back to release zip" }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to downloading the full release zip and extracting
|
||||
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
|
||||
val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress)
|
||||
return downloadedZip?.let { fileHandler.extractFirmware(it, hardware, internalFileExtension) }
|
||||
}
|
||||
|
||||
private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
|
||||
val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
|
||||
for (arch in knownArchs) {
|
||||
if (url.contains(arch, ignoreCase = true)) {
|
||||
return url.replace(arch, targetArch.lowercase(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles the logic for Over-the-Air (OTA) firmware updates via Bluetooth. */
|
||||
class OtaUpdateHandler
|
||||
@Inject
|
||||
constructor(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) {
|
||||
suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
address: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
notFoundMsg: String,
|
||||
startingMsg: String,
|
||||
firmwareUri: Uri? = null,
|
||||
): File? = try {
|
||||
updateState(FirmwareUpdateState.Downloading(0f))
|
||||
|
||||
if (firmwareUri != null) {
|
||||
initiateDfu(address, hardware, firmwareUri, updateState, startingMsg)
|
||||
null
|
||||
} else {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress ->
|
||||
updateState(FirmwareUpdateState.Downloading(progress))
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
updateState(FirmwareUpdateState.Error(notFoundMsg))
|
||||
null
|
||||
} else {
|
||||
initiateDfu(address, hardware, Uri.fromFile(firmwareFile), updateState, startingMsg)
|
||||
firmwareFile
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "OTA Update failed" }
|
||||
updateState(FirmwareUpdateState.Error(e.message ?: "OTA Update failed"))
|
||||
null
|
||||
}
|
||||
|
||||
private fun initiateDfu(
|
||||
address: String,
|
||||
deviceHardware: DeviceHardware,
|
||||
firmwareUri: Uri,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
startingMsg: String,
|
||||
) {
|
||||
updateState(FirmwareUpdateState.Processing(startingMsg))
|
||||
serviceRepository.meshService?.setDeviceAddress("n")
|
||||
|
||||
DfuServiceInitiator(address)
|
||||
.disableResume()
|
||||
.setDeviceName(deviceHardware.displayName)
|
||||
.setForceScanningForNewAddressInLegacyDfu(true)
|
||||
.setForeground(true)
|
||||
.setKeepBond(true)
|
||||
.setForceDfu(false)
|
||||
.setPrepareDataObjectDelay(DATA_OBJECT_DELAY)
|
||||
.setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN)
|
||||
.setScanTimeout(SCAN_TIMEOUT)
|
||||
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
|
||||
.setZip(firmwareUri)
|
||||
.start(context, FirmwareDfuService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles the logic for firmware updates via USB. */
|
||||
class UsbUpdateHandler
|
||||
@Inject
|
||||
constructor(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) {
|
||||
suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
rebootingMsg: String,
|
||||
firmwareUri: Uri? = null,
|
||||
): File? = try {
|
||||
updateState(FirmwareUpdateState.Downloading(0f))
|
||||
|
||||
if (firmwareUri != null) {
|
||||
updateState(FirmwareUpdateState.Processing(rebootingMsg))
|
||||
serviceRepository.meshService?.rebootToDfu()
|
||||
delay(REBOOT_DELAY)
|
||||
updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri))
|
||||
null
|
||||
} else {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveUsbFirmware(release, hardware) { progress ->
|
||||
updateState(FirmwareUpdateState.Downloading(progress))
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
updateState(FirmwareUpdateState.Error("Could not retrieve firmware file."))
|
||||
null
|
||||
} else {
|
||||
updateState(FirmwareUpdateState.Processing(rebootingMsg))
|
||||
serviceRepository.meshService?.rebootToDfu()
|
||||
delay(REBOOT_DELAY)
|
||||
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name))
|
||||
firmwareFile
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "USB Update failed" }
|
||||
updateState(FirmwareUpdateState.Error(e.message ?: "USB Update failed"))
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import co.touchlab.kermit.Logger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Manages USB-related interactions for firmware updates. */
|
||||
@Singleton
|
||||
class UsbManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
/** Observe when a USB device is detached. */
|
||||
fun deviceDetachFlow(): Flow<Unit> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
|
||||
trySend(Unit).isSuccess
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
.onFailure { Logger.w(it) { "Failed to unregister USB receiver" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import android.net.Uri
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.strings.firmware_update_rebooting
|
||||
import org.meshtastic.core.strings.firmware_update_retrieval_failed
|
||||
import org.meshtastic.core.strings.firmware_update_usb_failed
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val REBOOT_DELAY = 5000L
|
||||
private const val PERCENT_MAX = 100
|
||||
|
||||
/** Handles firmware updates via USB Mass Storage (UF2). */
|
||||
class UsbUpdateHandler
|
||||
@Inject
|
||||
constructor(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) : FirmwareUpdateHandler {
|
||||
|
||||
override suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
target: String, // Unused for USB
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri?,
|
||||
): File? =
|
||||
try {
|
||||
val downloadingMsg =
|
||||
getString(Res.string.firmware_update_downloading_percent, 0)
|
||||
.replace(Regex(":?\\s*%1\\\$d%?"), "")
|
||||
.trim()
|
||||
|
||||
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
|
||||
|
||||
val rebootingMsg = getString(Res.string.firmware_update_rebooting)
|
||||
|
||||
if (firmwareUri != null) {
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg)))
|
||||
serviceRepository.meshService?.rebootToDfu()
|
||||
delay(REBOOT_DELAY)
|
||||
|
||||
updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri))
|
||||
null
|
||||
} else {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveUsbFirmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
val retrievalFailedMsg = getString(Res.string.firmware_update_retrieval_failed)
|
||||
updateState(FirmwareUpdateState.Error(retrievalFailedMsg))
|
||||
null
|
||||
} else {
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg)))
|
||||
serviceRepository.meshService?.rebootToDfu()
|
||||
delay(REBOOT_DELAY)
|
||||
|
||||
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name))
|
||||
firmwareFile
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "USB Update failed" }
|
||||
val usbFailedMsg = getString(Res.string.firmware_update_usb_failed)
|
||||
updateState(FirmwareUpdateState.Error(e.message ?: usbFailedMsg))
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
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.client.distinctByPeripheral
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
|
||||
/**
|
||||
* BLE transport implementation for ESP32 Unified OTA protocol. Uses Nordic Kotlin-BLE-Library for modern coroutine
|
||||
* support.
|
||||
*
|
||||
* 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 address: String) : UnifiedOtaProtocol {
|
||||
|
||||
private val transportScope = CoroutineScope(SupervisorJob())
|
||||
private var peripheral: Peripheral? = null
|
||||
private var otaCharacteristic: RemoteCharacteristic? = null
|
||||
|
||||
private val responseChannel =
|
||||
kotlinx.coroutines.channels.Channel<String>(kotlinx.coroutines.channels.Channel.BUFFERED)
|
||||
|
||||
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.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private suspend fun scanForOtaDevice(): Peripheral? {
|
||||
// ESP32 OTA bootloader may use MAC address with last byte incremented by 1
|
||||
val otaAddress = calculateOtaAddress(address)
|
||||
val targetAddresses = setOf(address, otaAddress)
|
||||
Logger.i { "BLE OTA: Will match addresses: $targetAddresses" }
|
||||
|
||||
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 =
|
||||
centralManager
|
||||
.scan(SCAN_TIMEOUT)
|
||||
.distinctByPeripheral()
|
||||
.map { it.peripheral }
|
||||
.onEach { p ->
|
||||
if (foundDevices.add(p.address)) {
|
||||
Logger.d { "BLE OTA: Scan found device: ${p.address} (name=${p.name})" }
|
||||
}
|
||||
}
|
||||
.firstOrNull { it.address in targetAddresses }
|
||||
|
||||
if (peripheral != null) {
|
||||
Logger.i { "BLE OTA: Found target device at ${peripheral.address}" }
|
||||
return peripheral
|
||||
}
|
||||
|
||||
Logger.w { "BLE OTA: Target addresses $targetAddresses not in ${foundDevices.size} devices found" }
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
Logger.i { "BLE OTA: Device not found, waiting ${SCAN_RETRY_DELAY_MS}ms before retry..." }
|
||||
kotlinx.coroutines.delay(SCAN_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the potential OTA MAC address by incrementing the last byte. Some ESP32 bootloaders use MAC+1 for OTA
|
||||
* mode to distinguish from normal operation.
|
||||
*/
|
||||
@Suppress("MagicNumber", "ReturnCount")
|
||||
private fun calculateOtaAddress(macAddress: String): String {
|
||||
val parts = macAddress.split(":")
|
||||
if (parts.size != 6) return macAddress
|
||||
|
||||
val lastByte = parts[5].toIntOrNull(16) ?: return macAddress
|
||||
val incrementedByte = ((lastByte + 1) and 0xFF).toString(16).uppercase().padStart(2, '0')
|
||||
return parts.take(5).joinToString(":") + ":" + incrementedByte
|
||||
}
|
||||
|
||||
/** Connect to the device and discover OTA service. */
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun connect(): Result<Unit> = runCatching {
|
||||
Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." }
|
||||
kotlinx.coroutines.delay(REBOOT_DELAY_MS)
|
||||
|
||||
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 =
|
||||
scanForOtaDevice()
|
||||
?: throw OtaProtocolException.ConnectionFailed(
|
||||
"Device not found at address $address. " +
|
||||
"Ensure the device has rebooted into OTA mode and is advertising.",
|
||||
)
|
||||
|
||||
peripheral = p
|
||||
|
||||
centralManager.connect(
|
||||
peripheral = p,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
p.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
|
||||
// Monitor connection state
|
||||
p.state
|
||||
.onEach { state ->
|
||||
Logger.d { "BLE OTA: Connection state changed to $state" }
|
||||
if (state is ConnectionState.Disconnected) {
|
||||
isConnected = false
|
||||
}
|
||||
}
|
||||
.launchIn(transportScope)
|
||||
|
||||
// Wait for connection or failure with timeout
|
||||
// Don't use drop(1) - we might already be connected by the time we start collecting
|
||||
val connectionState =
|
||||
try {
|
||||
withTimeout(CONNECTION_TIMEOUT_MS) {
|
||||
p.state.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
|
||||
}
|
||||
} 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}")
|
||||
}
|
||||
|
||||
if (connectionState is ConnectionState.Disconnected) {
|
||||
Logger.w { "BLE OTA: Failed to connect to ${p.address} (state=$connectionState)" }
|
||||
throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${p.address}")
|
||||
}
|
||||
|
||||
Logger.i { "BLE OTA: Connected to ${p.address}, discovering services..." }
|
||||
|
||||
// Discover services
|
||||
val services = p.services(listOf(SERVICE_UUID.toKotlinUuid())).filterNotNull().first()
|
||||
val meshtasticOtaService =
|
||||
services.find { it.uuid == SERVICE_UUID.toKotlinUuid() }
|
||||
?: throw OtaProtocolException.ConnectionFailed("ESP32 OTA service not found")
|
||||
|
||||
otaCharacteristic =
|
||||
meshtasticOtaService.characteristics.find { it.uuid == OTA_CHARACTERISTIC_UUID.toKotlinUuid() }
|
||||
val txChar = meshtasticOtaService.characteristics.find { it.uuid == TX_CHARACTERISTIC_UUID.toKotlinUuid() }
|
||||
|
||||
if (otaCharacteristic == null || txChar == null) {
|
||||
throw OtaProtocolException.ConnectionFailed("Required characteristics not found")
|
||||
}
|
||||
|
||||
// Enable notifications and collect responses
|
||||
txChar
|
||||
.subscribe()
|
||||
.onEach { notifyBytes ->
|
||||
try {
|
||||
val response = notifyBytes.decodeToString()
|
||||
Logger.d { "BLE OTA: Received response: $response" }
|
||||
responseChannel.trySend(response)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "BLE OTA: Failed to decode response bytes" }
|
||||
}
|
||||
}
|
||||
.launchIn(transportScope)
|
||||
|
||||
isConnected = true
|
||||
Logger.i { "BLE OTA: Service discovered and ready" }
|
||||
}
|
||||
|
||||
override suspend fun startOta(
|
||||
sizeBytes: Long,
|
||||
sha256Hash: String,
|
||||
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
|
||||
): Result<Unit> = runCatching {
|
||||
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
|
||||
sendCommand(command)
|
||||
|
||||
var handshakeComplete = false
|
||||
while (!handshakeComplete) {
|
||||
val response = waitForResponse(ERASING_TIMEOUT_MS)
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ok -> handshakeComplete = true
|
||||
is OtaResponse.Erasing -> {
|
||||
Logger.i { "BLE OTA: Device erasing flash..." }
|
||||
onHandshakeStatus(OtaHandshakeStatus.Erasing)
|
||||
}
|
||||
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Rejected", ignoreCase = true)) {
|
||||
throw OtaProtocolException.HashRejected(sha256Hash)
|
||||
}
|
||||
throw OtaProtocolException.CommandFailed(command, parsed)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.w { "BLE OTA: Unexpected handshake response: $response" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun streamFirmware(
|
||||
data: ByteArray,
|
||||
chunkSize: Int,
|
||||
onProgress: suspend (Float) -> Unit,
|
||||
): Result<Unit> = runCatching {
|
||||
val totalBytes = data.size
|
||||
var sentBytes = 0
|
||||
|
||||
while (sentBytes < totalBytes) {
|
||||
if (!isConnected) {
|
||||
throw OtaProtocolException.TransferFailed("Connection lost during transfer")
|
||||
}
|
||||
|
||||
val remainingBytes = totalBytes - sentBytes
|
||||
val currentChunkSize = minOf(chunkSize, remainingBytes)
|
||||
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
|
||||
|
||||
// Write chunk
|
||||
writeData(chunk, WriteType.WITHOUT_RESPONSE)
|
||||
|
||||
// Wait for response (ACK or OK for last chunk)
|
||||
val response = waitForResponse(ACK_TIMEOUT_MS)
|
||||
val nextSentBytes = sentBytes + currentChunkSize
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ack -> {
|
||||
// Normal chunk success
|
||||
}
|
||||
|
||||
is OtaResponse.Ok -> {
|
||||
// OK indicates completion (usually on last chunk)
|
||||
if (nextSentBytes >= totalBytes) {
|
||||
sentBytes = nextSentBytes
|
||||
onProgress(1.0f)
|
||||
return@runCatching Unit
|
||||
} else {
|
||||
throw OtaProtocolException.TransferFailed("Premature OK received at offset $nextSentBytes")
|
||||
}
|
||||
}
|
||||
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
|
||||
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
|
||||
}
|
||||
throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
|
||||
}
|
||||
|
||||
else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response")
|
||||
}
|
||||
|
||||
sentBytes = nextSentBytes
|
||||
onProgress(sentBytes.toFloat() / totalBytes)
|
||||
}
|
||||
|
||||
// If we finished the loop without receiving OK, wait for it now
|
||||
val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS)
|
||||
when (val parsed = OtaResponse.parse(finalResponse)) {
|
||||
is OtaResponse.Ok -> Unit
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
|
||||
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
|
||||
}
|
||||
throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}")
|
||||
}
|
||||
|
||||
else -> throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $parsed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
peripheral?.disconnect()
|
||||
peripheral = null
|
||||
isConnected = false
|
||||
transportScope.cancel()
|
||||
}
|
||||
|
||||
private suspend fun sendCommand(command: OtaCommand) {
|
||||
val data = command.toString().toByteArray()
|
||||
writeData(data, WriteType.WITH_RESPONSE)
|
||||
}
|
||||
|
||||
private suspend fun writeData(data: ByteArray, writeType: WriteType) {
|
||||
val characteristic =
|
||||
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
|
||||
|
||||
try {
|
||||
characteristic.write(data, writeType = writeType)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
throw OtaProtocolException.TransferFailed("Failed to write data", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForResponse(timeoutMs: Long): String = try {
|
||||
withTimeout(timeoutMs) { responseChannel.receive() }
|
||||
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms")
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Service and Characteristic UUIDs from ESP32 Unified OTA spec
|
||||
private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import co.touchlab.kermit.Logger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.firmware_update_connecting_attempt
|
||||
import org.meshtastic.core.strings.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.strings.firmware_update_erasing
|
||||
import org.meshtastic.core.strings.firmware_update_hash_rejected
|
||||
import org.meshtastic.core.strings.firmware_update_loading
|
||||
import org.meshtastic.core.strings.firmware_update_ota_failed
|
||||
import org.meshtastic.core.strings.firmware_update_retrieval_failed
|
||||
import org.meshtastic.core.strings.firmware_update_starting_ota
|
||||
import org.meshtastic.core.strings.firmware_update_uploading
|
||||
import org.meshtastic.core.strings.firmware_update_waiting_reboot
|
||||
import org.meshtastic.feature.firmware.FirmwareRetriever
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateHandler
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import org.meshtastic.feature.firmware.ProgressState
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val RETRY_DELAY = 2000L
|
||||
private const val PERCENT_MAX = 100
|
||||
private const val KIB_DIVISOR = 1024f
|
||||
private const val MILLIS_PER_SECOND = 1000f
|
||||
|
||||
// Time to wait for OTA reboot packet to be sent before disconnecting mesh service
|
||||
private const val PACKET_SEND_DELAY_MS = 2000L
|
||||
|
||||
// Time to wait for Android BLE GATT to fully release after disconnecting mesh service
|
||||
private const val GATT_RELEASE_DELAY_MS = 1000L
|
||||
|
||||
/**
|
||||
* Handler for ESP32 firmware updates using the Unified OTA protocol. Supports both BLE and WiFi/TCP transports via
|
||||
* UnifiedOtaProtocol.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class Esp32OtaUpdateHandler
|
||||
@Inject
|
||||
constructor(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val centralManager: CentralManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : FirmwareUpdateHandler {
|
||||
|
||||
/** Entry point for FirmwareUpdateHandler interface. Decides between BLE and WiFi based on target format. */
|
||||
override suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
target: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri?,
|
||||
): File? = if (target.contains(":")) {
|
||||
startBleUpdate(release, hardware, target, updateState, firmwareUri)
|
||||
} else {
|
||||
startWifiUpdate(release, hardware, target, updateState, firmwareUri)
|
||||
}
|
||||
|
||||
private suspend fun startBleUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
address: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri? = null,
|
||||
): File? = performUpdate(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
transportFactory = { BleOtaTransport(centralManager, address) },
|
||||
rebootMode = 1,
|
||||
connectionAttempts = 5,
|
||||
)
|
||||
|
||||
private suspend fun startWifiUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
deviceIp: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri? = null,
|
||||
): File? = performUpdate(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
transportFactory = { WifiOtaTransport(deviceIp, WifiOtaTransport.DEFAULT_PORT) },
|
||||
rebootMode = 2,
|
||||
connectionAttempts = 10,
|
||||
)
|
||||
|
||||
private suspend fun performUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: Uri?,
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
rebootMode: Int,
|
||||
connectionAttempts: Int,
|
||||
): File? = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Step 1: Get firmware file
|
||||
val firmwareFile =
|
||||
obtainFirmwareFile(release, hardware, firmwareUri, updateState) ?: return@withContext null
|
||||
|
||||
// Step 2: Calculate Hash and Trigger Reboot
|
||||
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareFile)
|
||||
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
|
||||
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash" }
|
||||
triggerRebootOta(rebootMode, sha256Bytes)
|
||||
|
||||
// Step 3: Wait for packet to be sent, then disconnect mesh service
|
||||
// The packet needs ~1-2 seconds to be written and acknowledged over BLE
|
||||
delay(PACKET_SEND_DELAY_MS)
|
||||
disconnectMeshService()
|
||||
// Give BLE stack time to fully release the GATT connection
|
||||
delay(GATT_RELEASE_DELAY_MS)
|
||||
|
||||
val transport = transportFactory()
|
||||
if (!connectToDevice(transport, connectionAttempts, updateState)) return@withContext null
|
||||
|
||||
try {
|
||||
executeOtaSequence(transport, firmwareFile, sha256Hash, rebootMode, updateState)
|
||||
firmwareFile
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: OtaProtocolException.HashRejected) {
|
||||
Logger.e(e) { "ESP32 OTA: Hash rejected by device" }
|
||||
val msg = getString(Res.string.firmware_update_hash_rejected)
|
||||
updateState(FirmwareUpdateState.Error(msg))
|
||||
null
|
||||
} catch (e: OtaProtocolException) {
|
||||
Logger.e(e) { "ESP32 OTA: Protocol error" }
|
||||
val msg = getString(Res.string.firmware_update_ota_failed, e.message ?: "")
|
||||
updateState(FirmwareUpdateState.Error(msg))
|
||||
null
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "ESP32 OTA: Unexpected error" }
|
||||
val msg = getString(Res.string.firmware_update_ota_failed, e.message ?: "")
|
||||
updateState(FirmwareUpdateState.Error(msg))
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun downloadFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): File? {
|
||||
val downloadingMsg =
|
||||
getString(Res.string.firmware_update_downloading_percent, 0).replace(Regex(":?\\s*%1\\\$d%?"), "").trim()
|
||||
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
|
||||
return firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFirmwareFromUri(uri: Uri): File? = withContext(Dispatchers.IO) {
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
|
||||
val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin")
|
||||
tempFile.parentFile?.mkdirs()
|
||||
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
|
||||
tempFile
|
||||
}
|
||||
|
||||
private fun triggerRebootOta(mode: Int, hash: ByteArray?) {
|
||||
val service = serviceRepository.meshService ?: return
|
||||
try {
|
||||
val myInfo = service.getMyNodeInfo() ?: return
|
||||
Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" }
|
||||
service.requestRebootOta(service.getPacketId(), myInfo.myNodeNum, mode, hash)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "ESP32 OTA: Failed to trigger reboot OTA" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the mesh service BLE connection to free up the GATT for OTA. Setting device address to "n" (NOP
|
||||
* interface) cleanly disconnects without reconnection attempts.
|
||||
*/
|
||||
private fun disconnectMeshService() {
|
||||
try {
|
||||
Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" }
|
||||
serviceRepository.meshService?.setDeviceAddress("n")
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "ESP32 OTA: Error disconnecting mesh service" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun obtainFirmwareFile(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
firmwareUri: Uri?,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): File? {
|
||||
val firmwareFile =
|
||||
if (firmwareUri != null) {
|
||||
val loadingMsg = getString(Res.string.firmware_update_loading)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(loadingMsg)))
|
||||
getFirmwareFromUri(firmwareUri)
|
||||
} else {
|
||||
downloadFirmware(release, hardware, updateState)
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
val retrievalFailedMsg = getString(Res.string.firmware_update_retrieval_failed)
|
||||
updateState(FirmwareUpdateState.Error(retrievalFailedMsg))
|
||||
return null
|
||||
}
|
||||
return firmwareFile
|
||||
}
|
||||
|
||||
private suspend fun connectToDevice(
|
||||
transport: UnifiedOtaProtocol,
|
||||
attempts: Int,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): Boolean {
|
||||
// Show "waiting for reboot" state before first connection attempt
|
||||
val waitingMsg = getString(Res.string.firmware_update_waiting_reboot)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(waitingMsg)))
|
||||
|
||||
for (i in 1..attempts) {
|
||||
try {
|
||||
val connectingMsg = getString(Res.string.firmware_update_connecting_attempt, i, attempts)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(connectingMsg)))
|
||||
transport.connect().getOrThrow()
|
||||
return true
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
if (i == attempts) throw e
|
||||
delay(RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun executeOtaSequence(
|
||||
transport: UnifiedOtaProtocol,
|
||||
firmwareFile: File,
|
||||
sha256Hash: String,
|
||||
rebootMode: Int,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
) {
|
||||
// Step 5: Start OTA
|
||||
val startingOtaMsg = getString(Res.string.firmware_update_starting_ota)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(startingOtaMsg)))
|
||||
transport
|
||||
.startOta(sizeBytes = firmwareFile.length(), sha256Hash = sha256Hash) { status ->
|
||||
when (status) {
|
||||
OtaHandshakeStatus.Erasing -> {
|
||||
val erasingMsg = getString(Res.string.firmware_update_erasing)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(erasingMsg)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.getOrThrow()
|
||||
|
||||
// Step 6: Stream
|
||||
val uploadingMsg = getString(Res.string.firmware_update_uploading)
|
||||
updateState(FirmwareUpdateState.Updating(ProgressState(uploadingMsg, 0f)))
|
||||
val firmwareData = firmwareFile.readBytes()
|
||||
val chunkSize =
|
||||
if (rebootMode == 1) {
|
||||
BleOtaTransport.RECOMMENDED_CHUNK_SIZE
|
||||
} else {
|
||||
WifiOtaTransport.RECOMMENDED_CHUNK_SIZE
|
||||
}
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
transport
|
||||
.streamFirmware(
|
||||
data = firmwareData,
|
||||
chunkSize = chunkSize,
|
||||
onProgress = { progress ->
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val elapsedSeconds = (currentTime - startTime) / MILLIS_PER_SECOND
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
|
||||
val speedText =
|
||||
if (elapsedSeconds > 0) {
|
||||
val bytesSent = (progress * firmwareData.size).toLong()
|
||||
val kibPerSecond = (bytesSent / KIB_DIVISOR) / elapsedSeconds
|
||||
val remainingBytes = firmwareData.size - bytesSent
|
||||
val etaSeconds = if (kibPerSecond > 0) (remainingBytes / KIB_DIVISOR) / kibPerSecond else 0f
|
||||
|
||||
String.format(java.util.Locale.US, "%.1f KiB/s, ETA: %ds", kibPerSecond, etaSeconds.toInt())
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
updateState(
|
||||
FirmwareUpdateState.Updating(
|
||||
ProgressState(
|
||||
message = uploadingMsg,
|
||||
progress = progress,
|
||||
details = "$percent% ($speedText)",
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.getOrThrow()
|
||||
Logger.i { "ESP32 OTA: Firmware stream completed" }
|
||||
|
||||
updateState(FirmwareUpdateState.Success)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.feature.firmware.ota
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Utility functions for firmware hash calculation. */
|
||||
object FirmwareHashUtil {
|
||||
|
||||
private const val BUFFER_SIZE = 8192
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 hash of a file as a byte array.
|
||||
*
|
||||
* @param file Firmware file to hash
|
||||
* @return 32-byte SHA-256 hash
|
||||
*/
|
||||
fun calculateSha256Bytes(file: File): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
FileInputStream(file).use { fis ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytesRead: Int
|
||||
while (fis.read(buffer).also { bytesRead = it } != -1) {
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
return digest.digest()
|
||||
}
|
||||
|
||||
/** Convert byte array to hex string. */
|
||||
fun bytesToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
/** Commands supported by the ESP32 Unified OTA protocol. All commands are text-based and terminated with '\n'. */
|
||||
sealed class OtaCommand {
|
||||
/** Start OTA update with firmware size and SHA-256 hash */
|
||||
data class StartOta(val sizeBytes: Long, val sha256Hash: String) : OtaCommand() {
|
||||
override fun toString() = "OTA $sizeBytes $sha256Hash\n"
|
||||
}
|
||||
}
|
||||
|
||||
/** Responses from the ESP32 Unified OTA protocol. */
|
||||
sealed class OtaResponse {
|
||||
/** Successful response with optional data */
|
||||
data class Ok(
|
||||
val hwVersion: String? = null,
|
||||
val fwVersion: String? = null,
|
||||
val rebootCount: Int? = null,
|
||||
val gitHash: String? = null,
|
||||
) : OtaResponse()
|
||||
|
||||
/** Device is erasing flash partition (sent before OK after OTA command) */
|
||||
data object Erasing : OtaResponse()
|
||||
|
||||
/** Acknowledgment for received data chunk (BLE only) */
|
||||
data object Ack : OtaResponse()
|
||||
|
||||
/** Error response with message */
|
||||
data class Error(val message: String) : OtaResponse()
|
||||
|
||||
companion object {
|
||||
private const val OK_PREFIX_LENGTH = 3
|
||||
private const val ERR_PREFIX_LENGTH = 4
|
||||
private const val VERSION_PARTS_COUNT = 4
|
||||
|
||||
/**
|
||||
* Parse a response string from the device. Format examples:
|
||||
* - "OK\n"
|
||||
* - "OK 1 2.3.4 45 v2.3.4-abc123\n"
|
||||
* - "ERASING\n"
|
||||
* - "ACK\n"
|
||||
* - "ERR Hash Rejected\n"
|
||||
*/
|
||||
fun parse(response: String): OtaResponse {
|
||||
val trimmed = response.trim()
|
||||
|
||||
return when {
|
||||
trimmed == "OK" -> Ok()
|
||||
trimmed.startsWith("OK ") -> {
|
||||
val parts = trimmed.substring(OK_PREFIX_LENGTH).split(" ")
|
||||
when (parts.size) {
|
||||
VERSION_PARTS_COUNT ->
|
||||
Ok(
|
||||
hwVersion = parts[0],
|
||||
fwVersion = parts[1],
|
||||
rebootCount = parts[2].toIntOrNull(),
|
||||
gitHash = parts[3],
|
||||
)
|
||||
else -> Ok()
|
||||
}
|
||||
}
|
||||
trimmed == "ERASING" -> Erasing
|
||||
trimmed == "ACK" -> Ack
|
||||
trimmed.startsWith("ERR ") -> Error(trimmed.substring(ERR_PREFIX_LENGTH))
|
||||
trimmed == "ERR" -> Error("Unknown error")
|
||||
else -> Error("Unknown response: $trimmed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Status updates during the OTA handshake. */
|
||||
sealed class OtaHandshakeStatus {
|
||||
/** The device is erasing the flash partition. */
|
||||
data object Erasing : OtaHandshakeStatus()
|
||||
}
|
||||
|
||||
/** Interface for ESP32 Unified OTA protocol implementation. Supports both BLE and WiFi/TCP transports. */
|
||||
interface UnifiedOtaProtocol {
|
||||
/**
|
||||
* Connect to the device and discover OTA service/establish connection.
|
||||
*
|
||||
* @return Success if connected and ready, error otherwise
|
||||
*/
|
||||
suspend fun connect(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Start OTA update process.
|
||||
*
|
||||
* @param sizeBytes Total firmware size in bytes
|
||||
* @param sha256Hash SHA-256 hash of the firmware (64 hex characters)
|
||||
* @param onHandshakeStatus Optional callback to report status changes (e.g., "Erasing...")
|
||||
* @return Success if device accepts and is ready, error otherwise
|
||||
*/
|
||||
suspend fun startOta(
|
||||
sizeBytes: Long,
|
||||
sha256Hash: String,
|
||||
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit = {},
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Stream firmware binary data to the device.
|
||||
*
|
||||
* @param data Complete firmware binary
|
||||
* @param chunkSize Size of each chunk to send (256-512 for BLE, up to 1024 for WiFi)
|
||||
* @param onProgress Progress callback (0.0 to 1.0)
|
||||
* @return Success if all data transferred and verified, error otherwise
|
||||
*/
|
||||
suspend fun streamFirmware(data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit): Result<Unit>
|
||||
|
||||
/** Close the connection and cleanup resources. */
|
||||
suspend fun close()
|
||||
}
|
||||
|
||||
/** Exception thrown during OTA protocol operations. */
|
||||
sealed class OtaProtocolException(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
class ConnectionFailed(message: String, cause: Throwable? = null) : OtaProtocolException(message, cause)
|
||||
|
||||
class CommandFailed(val command: OtaCommand, val response: OtaResponse.Error) :
|
||||
OtaProtocolException("Command $command failed: ${response.message}")
|
||||
|
||||
class HashRejected(val providedHash: String) :
|
||||
OtaProtocolException("Device rejected hash: $providedHash (NVS mismatch)")
|
||||
|
||||
class TransferFailed(message: String, cause: Throwable? = null) : OtaProtocolException(message, cause)
|
||||
|
||||
class VerificationFailed(message: String) : OtaProtocolException(message)
|
||||
|
||||
class Timeout(message: String) : OtaProtocolException(message)
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* WiFi/TCP transport implementation for ESP32 Unified OTA protocol.
|
||||
*
|
||||
* Uses UDP for device discovery on port 3232, then establishes TCP connection for OTA commands and firmware streaming.
|
||||
*
|
||||
* Unlike BLE, WiFi transport:
|
||||
* - Uses synchronous TCP (no manual ACK waiting)
|
||||
* - Supports larger chunk sizes (up to 1024 bytes)
|
||||
* - Generally faster transfer speeds
|
||||
*/
|
||||
class WifiOtaTransport(private val deviceIpAddress: String, private val port: Int = DEFAULT_PORT) : UnifiedOtaProtocol {
|
||||
|
||||
private var socket: Socket? = null
|
||||
private var writer: OutputStreamWriter? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var isConnected = false
|
||||
|
||||
/** Connect to the device via TCP. */
|
||||
override suspend fun connect(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" }
|
||||
|
||||
socket =
|
||||
Socket().apply {
|
||||
soTimeout = SOCKET_TIMEOUT_MS
|
||||
connect(
|
||||
InetSocketAddress(deviceIpAddress, this@WifiOtaTransport.port),
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
writer = OutputStreamWriter(socket!!.getOutputStream(), Charsets.UTF_8)
|
||||
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), Charsets.UTF_8))
|
||||
isConnected = true
|
||||
|
||||
Logger.i { "WiFi OTA: Connected successfully" }
|
||||
}
|
||||
.onFailure { e ->
|
||||
Logger.e(e) { "WiFi OTA: Connection failed" }
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startOta(
|
||||
sizeBytes: Long,
|
||||
sha256Hash: String,
|
||||
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
|
||||
): Result<Unit> = runCatching {
|
||||
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
|
||||
sendCommand(command)
|
||||
|
||||
var handshakeComplete = false
|
||||
while (!handshakeComplete) {
|
||||
val response = readResponse(ERASING_TIMEOUT_MS)
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ok -> handshakeComplete = true
|
||||
is OtaResponse.Erasing -> {
|
||||
Logger.i { "WiFi OTA: Device erasing flash..." }
|
||||
onHandshakeStatus(OtaHandshakeStatus.Erasing)
|
||||
}
|
||||
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Rejected", ignoreCase = true)) {
|
||||
throw OtaProtocolException.HashRejected(sha256Hash)
|
||||
}
|
||||
throw OtaProtocolException.CommandFailed(command, parsed)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.w { "WiFi OTA: Unexpected handshake response: $response" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
override suspend fun streamFirmware(
|
||||
data: ByteArray,
|
||||
chunkSize: Int,
|
||||
onProgress: suspend (Float) -> Unit,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (!isConnected) {
|
||||
throw OtaProtocolException.TransferFailed("Not connected")
|
||||
}
|
||||
|
||||
val totalBytes = data.size
|
||||
var sentBytes = 0
|
||||
val outputStream = socket!!.getOutputStream()
|
||||
|
||||
while (sentBytes < totalBytes) {
|
||||
val remainingBytes = totalBytes - sentBytes
|
||||
val currentChunkSize = minOf(chunkSize, remainingBytes)
|
||||
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
|
||||
|
||||
// Write chunk directly to TCP stream
|
||||
outputStream.write(chunk)
|
||||
outputStream.flush()
|
||||
|
||||
// In the updated protocol, the device may send ACKs over WiFi too.
|
||||
// We check for any available responses without blocking too long.
|
||||
if (reader?.ready() == true) {
|
||||
val response = readResponse(ACK_TIMEOUT_MS)
|
||||
val nextSentBytes = sentBytes + currentChunkSize
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ack -> {
|
||||
// Normal chunk success
|
||||
}
|
||||
|
||||
is OtaResponse.Ok -> {
|
||||
// OK indicates completion (usually on last chunk)
|
||||
if (nextSentBytes >= totalBytes) {
|
||||
sentBytes = nextSentBytes
|
||||
onProgress(1.0f)
|
||||
return@runCatching Unit
|
||||
}
|
||||
}
|
||||
|
||||
is OtaResponse.Error -> {
|
||||
throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
|
||||
}
|
||||
|
||||
else -> {} // Ignore other responses during stream
|
||||
}
|
||||
}
|
||||
|
||||
sentBytes += currentChunkSize
|
||||
onProgress(sentBytes.toFloat() / totalBytes)
|
||||
|
||||
// Small delay to avoid overwhelming the device
|
||||
delay(WRITE_DELAY_MS)
|
||||
}
|
||||
|
||||
Logger.i { "WiFi OTA: Firmware streaming complete ($sentBytes bytes)" }
|
||||
|
||||
// Wait for final verification response (loop until OK or Error)
|
||||
var finalHandshakeComplete = false
|
||||
while (!finalHandshakeComplete) {
|
||||
val finalResponse = readResponse(VERIFICATION_TIMEOUT_MS)
|
||||
when (val parsed = OtaResponse.parse(finalResponse)) {
|
||||
is OtaResponse.Ok -> finalHandshakeComplete = true
|
||||
is OtaResponse.Ack -> {} // Ignore late ACKs
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
|
||||
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
|
||||
}
|
||||
throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}")
|
||||
}
|
||||
|
||||
else ->
|
||||
throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $finalResponse")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
writer?.close()
|
||||
reader?.close()
|
||||
socket?.close()
|
||||
}
|
||||
writer = null
|
||||
reader = null
|
||||
socket = null
|
||||
isConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendCommand(command: OtaCommand) = withContext(Dispatchers.IO) {
|
||||
val w = writer ?: throw OtaProtocolException.ConnectionFailed("Not connected")
|
||||
val commandStr = command.toString()
|
||||
Logger.d { "WiFi OTA: Sending command: ${commandStr.trim()}" }
|
||||
w.write(commandStr)
|
||||
w.flush()
|
||||
}
|
||||
|
||||
private suspend fun readResponse(timeoutMs: Long = COMMAND_TIMEOUT_MS): String = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeout(timeoutMs) {
|
||||
val r = reader ?: throw OtaProtocolException.ConnectionFailed("Not connected")
|
||||
val response = r.readLine() ?: throw OtaProtocolException.ConnectionFailed("Connection closed")
|
||||
Logger.d { "WiFi OTA: Received response: $response" }
|
||||
response
|
||||
}
|
||||
} catch (@Suppress("SwallowedException") e: SocketTimeoutException) {
|
||||
throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PORT = 3232
|
||||
const val RECOMMENDED_CHUNK_SIZE = 1024 // Larger than BLE
|
||||
private const val RECEIVE_BUFFER_SIZE = 1024
|
||||
private const val DISCOVERY_TIMEOUT_DEFAULT = 3000L
|
||||
private const val BROADCAST_ADDRESS = "255.255.255.255"
|
||||
|
||||
// Timeouts
|
||||
private const val CONNECTION_TIMEOUT_MS = 5_000
|
||||
private const val SOCKET_TIMEOUT_MS = 15_000
|
||||
private const val COMMAND_TIMEOUT_MS = 10_000L
|
||||
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 WRITE_DELAY_MS = 10L // Shorter than BLE
|
||||
|
||||
/**
|
||||
* Discover ESP32 devices on the local network via UDP broadcast.
|
||||
*
|
||||
* @return List of discovered device IP addresses
|
||||
*/
|
||||
suspend fun discoverDevices(timeoutMs: Long = DISCOVERY_TIMEOUT_DEFAULT): List<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val devices = mutableListOf<String>()
|
||||
|
||||
runCatching {
|
||||
DatagramSocket().use { socket ->
|
||||
socket.broadcast = true
|
||||
socket.soTimeout = timeoutMs.toInt()
|
||||
|
||||
// Send discovery broadcast
|
||||
val discoveryMessage = "MESHTASTIC_OTA_DISCOVERY\n".toByteArray()
|
||||
val broadcastAddress = InetAddress.getByName(BROADCAST_ADDRESS)
|
||||
val packet =
|
||||
DatagramPacket(discoveryMessage, discoveryMessage.size, broadcastAddress, DEFAULT_PORT)
|
||||
socket.send(packet)
|
||||
Logger.d { "WiFi OTA: Sent discovery broadcast" }
|
||||
|
||||
// Listen for responses
|
||||
val receiveBuffer = ByteArray(RECEIVE_BUFFER_SIZE)
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
while (System.currentTimeMillis() - startTime < timeoutMs) {
|
||||
try {
|
||||
val receivePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
|
||||
socket.receive(receivePacket)
|
||||
|
||||
val response = String(receivePacket.data, 0, receivePacket.length).trim()
|
||||
if (response.startsWith("MESHTASTIC_OTA")) {
|
||||
val deviceIp = receivePacket.address.hostAddress
|
||||
if (deviceIp != null && !devices.contains(deviceIp)) {
|
||||
devices.add(deviceIp)
|
||||
Logger.i { "WiFi OTA: Discovered device at $deviceIp" }
|
||||
}
|
||||
}
|
||||
} catch (@Suppress("SwallowedException") e: SocketTimeoutException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e -> Logger.e(e) { "WiFi OTA: Discovery failed" } }
|
||||
|
||||
devices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* 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.feature.firmware
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
|
||||
class FirmwareRetrieverTest {
|
||||
|
||||
private val fileHandler: FirmwareFileHandler = mockk()
|
||||
private val retriever = FirmwareRetriever(fileHandler)
|
||||
|
||||
@Test
|
||||
fun `retrieveEsp32Firmware uses mt-arch-ota bin when Unified OTA is supported`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
hwModelSlug = "HELTEC_V3",
|
||||
platformioTarget = "heltec-v3",
|
||||
architecture = "esp32-s3",
|
||||
supportsUnifiedOta = true,
|
||||
)
|
||||
val expectedFile = File("mt-esp32s3-ota.bin")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveEsp32Firmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin",
|
||||
)
|
||||
fileHandler.downloadFile(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin",
|
||||
"mt-esp32s3-ota.bin",
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveEsp32Firmware falls back to board-specific bin when mt-arch-ota bin is missing`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
hwModelSlug = "HELTEC_V3",
|
||||
platformioTarget = "heltec-v3",
|
||||
architecture = "esp32-s3",
|
||||
supportsUnifiedOta = true,
|
||||
)
|
||||
val expectedFile = File("firmware-heltec-v3-2.5.0.bin")
|
||||
|
||||
// First check for mt-esp32s3-ota.bin fails
|
||||
coEvery { fileHandler.checkUrlExists(match { it.contains("mt-esp32s3-ota.bin") }) } returns false
|
||||
// ZIP download fails too for the OTA attempt to reach second retrieve call
|
||||
coEvery { fileHandler.downloadFile(any(), "firmware_release.zip", any()) } returns null
|
||||
|
||||
// Second check for board-specific bin succeeds
|
||||
coEvery { fileHandler.checkUrlExists(match { it.contains("firmware-heltec-v3") }) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), "firmware-heltec-v3-2.5.0.bin", any()) } returns expectedFile
|
||||
coEvery { fileHandler.extractFirmware(any<File>(), any(), any(), any()) } returns null
|
||||
|
||||
val result = retriever.retrieveEsp32Firmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin",
|
||||
)
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-heltec-v3-2.5.0.bin",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveEsp32Firmware uses legacy filename for devices without Unified OTA`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
hwModelSlug = "TLORA_V2",
|
||||
platformioTarget = "tlora-v2",
|
||||
architecture = "esp32",
|
||||
supportsUnifiedOta = false,
|
||||
)
|
||||
val expectedFile = File("firmware-tlora-v2-2.5.0.bin")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveEsp32Firmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tlora-v2-2.5.0.bin",
|
||||
)
|
||||
fileHandler.downloadFile(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tlora-v2-2.5.0.bin",
|
||||
"firmware-tlora-v2-2.5.0.bin",
|
||||
any(),
|
||||
)
|
||||
}
|
||||
// Verify we DID NOT check for mt-esp32-ota.bin
|
||||
coVerify(exactly = 0) { fileHandler.checkUrlExists(match { it.contains("mt-esp32-ota.bin") }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveOtaFirmware uses correct zip extension for NRF52`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
hwModelSlug = "RAK4631",
|
||||
platformioTarget = "rak4631",
|
||||
architecture = "nrf52840",
|
||||
supportsUnifiedOta = false, // OTA via DFU zip
|
||||
)
|
||||
val expectedFile = File("firmware-rak4631-2.5.0-ota.zip")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveOtaFirmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-rak4631-2.5.0-ota.zip",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveUsbFirmware uses correct uf2 extension for RP2040`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/rp2040.zip")
|
||||
val hardware = DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040")
|
||||
val expectedFile = File("firmware-pico-2.5.0.uf2")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveUsbFirmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-pico-2.5.0.uf2",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteService
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
|
||||
private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
class BleOtaTransportTest {
|
||||
|
||||
private val centralManager: CentralManager = mockk()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val transport = BleOtaTransport(centralManager, address)
|
||||
|
||||
@Test
|
||||
fun `race condition check - response before waitForResponse`() = runTest {
|
||||
val peripheral: Peripheral = mockk(relaxed = true)
|
||||
val otaChar: RemoteCharacteristic = mockk(relaxed = true)
|
||||
val txChar: RemoteCharacteristic = mockk(relaxed = true)
|
||||
val service: RemoteService = mockk(relaxed = true)
|
||||
|
||||
every { centralManager.getBondedPeripherals() } returns listOf(peripheral)
|
||||
every { peripheral.address } returns address
|
||||
every { peripheral.state } returns MutableStateFlow(ConnectionState.Connected)
|
||||
|
||||
coEvery { peripheral.services(any()) } returns MutableStateFlow(listOf(service))
|
||||
every { service.uuid } returns SERVICE_UUID.toKotlinUuid()
|
||||
every { service.characteristics } returns listOf(otaChar, txChar)
|
||||
every { otaChar.uuid } returns OTA_CHARACTERISTIC_UUID.toKotlinUuid()
|
||||
every { txChar.uuid } returns TX_CHARACTERISTIC_UUID.toKotlinUuid()
|
||||
|
||||
coEvery { centralManager.connect(any(), any()) } returns Unit
|
||||
|
||||
val notificationFlow = MutableSharedFlow<ByteArray>()
|
||||
every { txChar.subscribe() } returns notificationFlow
|
||||
|
||||
// Connect
|
||||
transport.connect().getOrThrow()
|
||||
|
||||
// Simulate sending a command and getting a response BEFORE calling startOta
|
||||
// This is tricky to simulate exactly as in the real race, but we can verify
|
||||
// if responseFlow is indeed dropping messages.
|
||||
|
||||
// In startOta:
|
||||
// 1. sendCommand(command)
|
||||
// 2. waitForResponse() -> responseFlow.first()
|
||||
|
||||
// If the device is super fast, the notification arrives between 1 and 2.
|
||||
|
||||
val size = 100L
|
||||
val hash = "hash"
|
||||
|
||||
// We mock write to immediately emit to notificationFlow
|
||||
coEvery { otaChar.write(any(), any()) } coAnswers
|
||||
{
|
||||
println("Mock writing, emitting OK to notificationFlow")
|
||||
notificationFlow.emit("OK\n".toByteArray())
|
||||
println("OK emitted to notificationFlow")
|
||||
}
|
||||
|
||||
println("Calling startOta")
|
||||
val result = transport.startOta(size, hash) {}
|
||||
println("startOta result: $result")
|
||||
|
||||
assert(result.isSuccess)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
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
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.feature.firmware.FirmwareRetriever
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class Esp32OtaUpdateHandlerTest {
|
||||
|
||||
private val firmwareRetriever: FirmwareRetriever = mockk()
|
||||
private val serviceRepository: ServiceRepository = mockk()
|
||||
private val centralManager: CentralManager = mockk()
|
||||
private val context: Context = mockk()
|
||||
private val contentResolver: ContentResolver = mockk()
|
||||
|
||||
private val handler = Esp32OtaUpdateHandler(firmwareRetriever, serviceRepository, centralManager, context)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
|
||||
coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String"
|
||||
coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } answers
|
||||
{
|
||||
val args = secondArg<Array<Any?>>()
|
||||
if (args.isNotEmpty()) {
|
||||
"OTA update failed: ${args[0]}"
|
||||
} else {
|
||||
"Mocked String with args"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdate from URI propagates exception when reading fails`() = runTest {
|
||||
val release = FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = "")
|
||||
val hardware = DeviceHardware(hwModelSlug = "V3", architecture = "esp32")
|
||||
val target = "00:11:22:33:44:55"
|
||||
val uri: Uri = mockk()
|
||||
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { contentResolver.openInputStream(uri) } throws IOException("Read error")
|
||||
|
||||
val states = mutableListOf<FirmwareUpdateState>()
|
||||
|
||||
handler.startUpdate(release, hardware, target, { states.add(it) }, uri)
|
||||
|
||||
// Before fix, this would be FirmwareUpdateState.Error("Could not retrieve firmware file.")
|
||||
// After fix, it should ideally contain "Read error" or be the original exception if we don't catch it too
|
||||
// early.
|
||||
// Esp32OtaUpdateHandler.performUpdate catches Exception and uses e.message.
|
||||
|
||||
val lastState = states.last()
|
||||
assert(lastState is FirmwareUpdateState.Error)
|
||||
assertEquals("OTA update failed: Read error", (lastState as FirmwareUpdateState.Error).error)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UnifiedOtaProtocolTest {
|
||||
|
||||
@Test
|
||||
fun `OtaCommand StartOta produces correct command string`() {
|
||||
val size = 123456L
|
||||
val hash = "abc123def456"
|
||||
val command = OtaCommand.StartOta(size, hash)
|
||||
|
||||
assertEquals("OTA 123456 abc123def456\n", command.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OtaCommand StartOta handles large size and long hash`() {
|
||||
val size = 4294967295L
|
||||
val hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
val command = OtaCommand.StartOta(size, hash)
|
||||
|
||||
assertEquals(
|
||||
"OTA 4294967295 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n",
|
||||
command.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OtaResponse parse handles basic success cases`() {
|
||||
assertEquals(OtaResponse.Ok(), OtaResponse.parse("OK"))
|
||||
assertEquals(OtaResponse.Ok(), OtaResponse.parse("OK\n"))
|
||||
assertEquals(OtaResponse.Ack, OtaResponse.parse("ACK"))
|
||||
assertEquals(OtaResponse.Erasing, OtaResponse.parse("ERASING"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OtaResponse parse handles detailed OK with version info`() {
|
||||
val response = OtaResponse.parse("OK 1.0 2.3.4 42 v2.3.4-abc123\n")
|
||||
|
||||
assert(response is OtaResponse.Ok)
|
||||
val ok = response as OtaResponse.Ok
|
||||
assertEquals("1.0", ok.hwVersion)
|
||||
assertEquals("2.3.4", ok.fwVersion)
|
||||
assertEquals(42, ok.rebootCount)
|
||||
assertEquals("v2.3.4-abc123", ok.gitHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OtaResponse parse handles detailed OK with partial data`() {
|
||||
// Test with fewer than expected parts (should fallback to basic OK)
|
||||
val response = OtaResponse.parse("OK 1.0 2.3.4\n")
|
||||
assertEquals(OtaResponse.Ok(), response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OtaResponse parse handles error cases`() {
|
||||
val err1 = OtaResponse.parse("ERR Hash Rejected")
|
||||
assert(err1 is OtaResponse.Error)
|
||||
assertEquals("Hash Rejected", (err1 as OtaResponse.Error).message)
|
||||
|
||||
val err2 = OtaResponse.parse("ERR")
|
||||
assert(err2 is OtaResponse.Error)
|
||||
assertEquals("Unknown error", (err2 as OtaResponse.Error).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OtaResponse parse handles malformed or unexpected input`() {
|
||||
val response = OtaResponse.parse("RANDOM_GARBAGE")
|
||||
assert(response is OtaResponse.Error)
|
||||
assertEquals("Unknown response: RANDOM_GARBAGE", (response as OtaResponse.Error).message)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue