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
|
|
@ -4,21 +4,164 @@
|
|||
"hwModel": 18,
|
||||
"hwModelSlug": "NANO_G2_ULTRA",
|
||||
"requiresBootloaderUpgradeForOta": true,
|
||||
"infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
|
||||
"infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/"
|
||||
},
|
||||
{
|
||||
"hwModel": 9,
|
||||
"hwModelSlug": "RAK4631",
|
||||
"requiresBootloaderUpgradeForOta": true,
|
||||
"infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
|
||||
"infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/"
|
||||
},
|
||||
{
|
||||
"hwModel": 96,
|
||||
"hwModelSlug": "NOMADSTAR_METEOR_PRO",
|
||||
"requiresBootloaderUpgradeForOta": true,
|
||||
"infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
|
||||
"infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/"
|
||||
},
|
||||
{
|
||||
"hwModel": 12,
|
||||
"hwModelSlug": "LILYGO_TBEAM_S3_CORE",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 16,
|
||||
"hwModelSlug": "TLORA_T3_S3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 28,
|
||||
"hwModelSlug": "SENSELORA_S3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 43,
|
||||
"hwModelSlug": "HELTEC_V3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 44,
|
||||
"hwModelSlug": "HELTEC_WSL_V3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 45,
|
||||
"hwModelSlug": "BETAFPV_2400_TX",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 46,
|
||||
"hwModelSlug": "BETAFPV_900_NANO_TX",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 48,
|
||||
"hwModelSlug": "HELTEC_WIRELESS_TRACKER",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 49,
|
||||
"hwModelSlug": "HELTEC_WIRELESS_PAPER",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 50,
|
||||
"hwModelSlug": "T_DECK",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 51,
|
||||
"hwModelSlug": "T_WATCH_S3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 52,
|
||||
"hwModelSlug": "PICOMPUTER_S3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 53,
|
||||
"hwModelSlug": "HELTEC_HT62",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 54,
|
||||
"hwModelSlug": "EBYTE_ESP32_S3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 55,
|
||||
"hwModelSlug": "ESP32_S3_PICO",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 56,
|
||||
"hwModelSlug": "CHATTER_2",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 57,
|
||||
"hwModelSlug": "HELTEC_WIRELESS_PAPER_V1_0",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 58,
|
||||
"hwModelSlug": "HELTEC_WIRELESS_TRACKER_V1_0",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 59,
|
||||
"hwModelSlug": "UNPHONE",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 61,
|
||||
"hwModelSlug": "CDEBYTE_EORA_S3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 64,
|
||||
"hwModelSlug": "RADIOMASTER_900_BANDIT_NANO",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 65,
|
||||
"hwModelSlug": "HELTEC_CAPSULE_SENSOR_V3",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 66,
|
||||
"hwModelSlug": "HELTEC_VISION_MASTER_T190",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 67,
|
||||
"hwModelSlug": "HELTEC_VISION_MASTER_E213",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 68,
|
||||
"hwModelSlug": "HELTEC_VISION_MASTER_E290",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 70,
|
||||
"hwModelSlug": "SENSECAP_INDICATOR",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 74,
|
||||
"hwModelSlug": "RADIOMASTER_900_BANDIT",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 107,
|
||||
"hwModelSlug": "THINKNODE_M5",
|
||||
"supportsUnifiedOta": true
|
||||
},
|
||||
{
|
||||
"hwModel": 110,
|
||||
"hwModelSlug": "HELTEC_V4",
|
||||
"supportsUnifiedOta": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -151,69 +151,76 @@ constructor(
|
|||
val attemptStart = System.currentTimeMillis()
|
||||
Logger.i { "[$address] TCP connection attempt starting..." }
|
||||
|
||||
val (host, port) =
|
||||
address.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: SERVICE_PORT) }
|
||||
val parts = address.split(":", limit = 2)
|
||||
val host = parts[0]
|
||||
val port = parts.getOrNull(1)?.toIntOrNull() ?: SERVICE_PORT
|
||||
Logger.i { "[$address] Parsed address. Host: $host, Port: $port" }
|
||||
|
||||
Logger.d { "[$address] Resolving host '$host' and connecting to port $port..." }
|
||||
|
||||
Socket(InetAddress.getByName(host), port).use { socket ->
|
||||
socket.tcpNoDelay = true
|
||||
socket.soTimeout = SOCKET_TIMEOUT
|
||||
this@TCPInterface.socket = socket
|
||||
try {
|
||||
Socket(InetAddress.getByName(host), port).use { socket ->
|
||||
socket.tcpNoDelay = true
|
||||
socket.soTimeout = SOCKET_TIMEOUT
|
||||
this@TCPInterface.socket = socket
|
||||
|
||||
val connectTime = System.currentTimeMillis() - attemptStart
|
||||
connectionStartTime = System.currentTimeMillis()
|
||||
Logger.i {
|
||||
"[$address] TCP socket connected in ${connectTime}ms - " +
|
||||
"Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}"
|
||||
}
|
||||
val connectTime = System.currentTimeMillis() - attemptStart
|
||||
connectionStartTime = System.currentTimeMillis()
|
||||
Logger.i {
|
||||
"[$address] TCP socket connected in ${connectTime}ms - " +
|
||||
"Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}"
|
||||
}
|
||||
|
||||
BufferedOutputStream(socket.getOutputStream()).use { outputStream ->
|
||||
outStream = outputStream
|
||||
BufferedOutputStream(socket.getOutputStream()).use { outputStream ->
|
||||
outStream = outputStream
|
||||
|
||||
BufferedInputStream(socket.getInputStream()).use { inputStream ->
|
||||
super.connect()
|
||||
BufferedInputStream(socket.getInputStream()).use { inputStream ->
|
||||
super.connect()
|
||||
|
||||
retryCount = 1
|
||||
backoffDelay = MIN_BACKOFF_MILLIS
|
||||
retryCount = 1
|
||||
backoffDelay = MIN_BACKOFF_MILLIS
|
||||
|
||||
var timeoutCount = 0
|
||||
while (timeoutCount < SOCKET_RETRIES) {
|
||||
try { // close after 90s of inactivity
|
||||
val c = inputStream.read()
|
||||
if (c == -1) {
|
||||
Logger.w {
|
||||
"[$address] TCP got EOF on stream after $packetsReceived packets received"
|
||||
var timeoutCount = 0
|
||||
while (timeoutCount < SOCKET_RETRIES) {
|
||||
try { // close after 90s of inactivity
|
||||
val c = inputStream.read()
|
||||
if (c == -1) {
|
||||
Logger.w {
|
||||
"[$address] TCP got EOF on stream after $packetsReceived packets received"
|
||||
}
|
||||
break
|
||||
} else {
|
||||
timeoutCount = 0
|
||||
packetsReceived++
|
||||
bytesReceived++
|
||||
readChar(c.toByte())
|
||||
}
|
||||
break
|
||||
} else {
|
||||
timeoutCount = 0
|
||||
packetsReceived++
|
||||
bytesReceived++
|
||||
readChar(c.toByte())
|
||||
}
|
||||
} catch (ex: SocketTimeoutException) {
|
||||
timeoutCount++
|
||||
timeoutEvents++
|
||||
if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) {
|
||||
Logger.d {
|
||||
"[$address] TCP socket timeout count: $timeoutCount/$SOCKET_RETRIES " +
|
||||
"(total timeouts: $timeoutEvents)"
|
||||
} catch (ex: SocketTimeoutException) {
|
||||
timeoutCount++
|
||||
timeoutEvents++
|
||||
if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) {
|
||||
Logger.d {
|
||||
"[$address] TCP socket timeout count: $timeoutCount/$SOCKET_RETRIES " +
|
||||
"(total timeouts: $timeoutEvents)"
|
||||
}
|
||||
}
|
||||
// Ignore and start another read
|
||||
}
|
||||
// Ignore and start another read
|
||||
}
|
||||
}
|
||||
if (timeoutCount >= SOCKET_RETRIES) {
|
||||
val inactivityMs = SOCKET_RETRIES * SOCKET_TIMEOUT
|
||||
Logger.w {
|
||||
"[$address] TCP closing connection due to $SOCKET_RETRIES consecutive timeouts " +
|
||||
"(${inactivityMs}ms of inactivity)"
|
||||
if (timeoutCount >= SOCKET_RETRIES) {
|
||||
val inactivityMs = SOCKET_RETRIES * SOCKET_TIMEOUT
|
||||
Logger.w {
|
||||
"[$address] TCP closing connection due to $SOCKET_RETRIES consecutive timeouts " +
|
||||
"(${inactivityMs}ms of inactivity)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onDeviceDisconnect(false)
|
||||
}
|
||||
onDeviceDisconnect(false)
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "[$address] Error connecting to $host:$port" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package com.geeksville.mesh.service
|
|||
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import com.google.protobuf.ByteString
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -302,6 +303,16 @@ constructor(
|
|||
commandSender.sendAdmin(destNum, requestId) { rebootSeconds = DEFAULT_REBOOT_DELAY }
|
||||
}
|
||||
|
||||
fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
|
||||
val otaMode = AdminProtos.OTAMode.forNumber(mode) ?: AdminProtos.OTAMode.NO_REBOOT_OTA
|
||||
val otaEventBuilder = AdminProtos.AdminMessage.OTAEvent.newBuilder()
|
||||
otaEventBuilder.rebootOtaMode = otaMode
|
||||
if (hash != null) {
|
||||
otaEventBuilder.otaHash = ByteString.copyFrom(hash)
|
||||
}
|
||||
commandSender.sendAdmin(destNum, requestId) { otaRequest = otaEventBuilder.build() }
|
||||
}
|
||||
|
||||
fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
|
||||
commandSender.sendAdmin(destNum, requestId) { factoryResetDevice = 1 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import org.meshtastic.core.service.ServiceRepository
|
|||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@Suppress("TooManyFunctions", "LargeClass")
|
||||
class MeshService : Service() {
|
||||
|
||||
@Inject lateinit var radioInterfaceService: RadioInterfaceService
|
||||
|
|
@ -342,5 +343,10 @@ class MeshService : Service() {
|
|||
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions {
|
||||
router.actionHandler.handleRequestTelemetry(requestId, destNum, type)
|
||||
}
|
||||
|
||||
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) =
|
||||
toRemoteExceptions {
|
||||
router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
|||
message: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateWaypointNotification(
|
||||
|
|
@ -94,6 +95,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
|||
name: String,
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
|
|
@ -102,6 +104,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
|||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ constructor(
|
|||
}
|
||||
base.copy(
|
||||
requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
|
||||
supportsUnifiedOta = quirk.supportsUnifiedOta,
|
||||
bootloaderInfoUrl = quirk.infoUrl,
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
|
|
@ -73,4 +72,5 @@ fun FirmwareRelease.asDeviceVersion(): DeviceVersion = DeviceVersion(id.substrin
|
|||
enum class FirmwareReleaseType {
|
||||
STABLE,
|
||||
ALPHA,
|
||||
LOCAL,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.core.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
|
|
@ -31,6 +30,11 @@ data class BootloaderOtaQuirk(
|
|||
* one-time bootloader upgrade (typically via USB) before DFU updates from the app work.
|
||||
*/
|
||||
@SerialName("requiresBootloaderUpgradeForOta") val requiresBootloaderUpgradeForOta: Boolean = false,
|
||||
/**
|
||||
* Indicates that the device supports the ESP32 Unified OTA protocol. When true, the app will use the unified OTA
|
||||
* handler instead of Nordic DFU.
|
||||
*/
|
||||
@SerialName("supportsUnifiedOta") val supportsUnifiedOta: Boolean = false,
|
||||
/** Optional URL pointing to documentation on how to update the bootloader. */
|
||||
@SerialName("infoUrl") val infoUrl: String? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,4 +55,8 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
|
|||
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
|
||||
val supportsQrCodeSharing: Boolean
|
||||
get() = isSupported("2.6.8")
|
||||
|
||||
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
|
||||
val supportsEsp32Ota: Boolean
|
||||
get() = isSupported("2.7.18")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.core.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -39,6 +38,7 @@ data class DeviceHardware(
|
|||
val requiresBootloaderUpgradeForOta: Boolean? = null,
|
||||
/** Optional URL pointing to documentation for upgrading the bootloader. */
|
||||
val bootloaderInfoUrl: String? = null,
|
||||
val supportsUnifiedOta: Boolean = false,
|
||||
val supportLevel: Int? = null,
|
||||
val tags: List<String>? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -182,4 +182,11 @@ interface IMeshService {
|
|||
|
||||
/// Send request for telemetry to nodeNum
|
||||
void requestTelemetry(in int requestId, in int destNum, in int type);
|
||||
|
||||
/**
|
||||
* Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only)
|
||||
* mode is 1 for BLE, 2 for WiFi
|
||||
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
|
||||
*/
|
||||
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -562,7 +562,7 @@
|
|||
<string name="triple_click_adhoc_ping">Triple Click Ad Hoc Ping</string>
|
||||
<string name="time_zone">Time Zone</string>
|
||||
<string name="led_heartbeat">LED Heartbeat</string>
|
||||
<string name="display_config">Display</string>
|
||||
<string name="display_config">Device Display</string>
|
||||
<string name="screen_on_for">Screen on for</string>
|
||||
<string name="carousel_interval">Carousel interval</string>
|
||||
<string name="compass_north_top">Compass north top</string>
|
||||
|
|
@ -1022,17 +1022,20 @@
|
|||
<string name="firmware_update_checking">Checking for updates...</string>
|
||||
<string name="firmware_update_device">Device: %1$s</string>
|
||||
<string name="firmware_update_currently_installed">Currently Installed: %1$s</string>
|
||||
<string name="firmware_update_latest">Latest Release: %1$s</string>
|
||||
<string name="firmware_update_latest">Update To: %1$s</string>
|
||||
<string name="firmware_update_stable">Stable</string>
|
||||
<string name="firmware_update_alpha">Alpha</string>
|
||||
<string name="firmware_update_disconnect_warning">Note: This will temporarily disconnect your device during the update.</string>
|
||||
<string name="firmware_update_downloading">Downloading firmware... %1$d%</string>
|
||||
<string name="firmware_update_downloading_percent">Downloading firmware... %1$d%%</string>
|
||||
<string name="firmware_update_error">Error: %1$s</string>
|
||||
<string name="firmware_update_retry">Retry</string>
|
||||
<string name="firmware_update_success">Update Successful!</string>
|
||||
<string name="firmware_update_done">Done</string>
|
||||
<string name="firmware_update_starting_dfu">Starting DFU...</string>
|
||||
<string name="firmware_update_updating">Updating... %1$s%</string>
|
||||
<string name="firmware_update_updating">Updating... %1$s</string>
|
||||
<string name="firmware_update_enabling_dfu">Enabling DFU mode...</string>
|
||||
<string name="firmware_update_validating">Validating firmware...</string>
|
||||
<string name="firmware_update_disconnecting">Disconnecting...</string>
|
||||
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
|
||||
<string name="firmware_update_invalid_address">Connected device is not a valid BLE device or address is unknown (%1$s).</string>
|
||||
<string name="firmware_update_no_device">No device connected</string>
|
||||
|
|
@ -1046,6 +1049,8 @@
|
|||
<string name="firmware_update_almost_there">Almost there...</string>
|
||||
<string name="firmware_update_taking_a_while">This might take a minute...</string>
|
||||
<string name="firmware_update_select_file">Select Local File</string>
|
||||
<string name="firmware_update_local_file">Local File</string>
|
||||
<string name="firmware_update_source_local">Source: Local File</string>
|
||||
<string name="firmware_update_unknown_release">Unknown remote release</string>
|
||||
<string name="firmware_update_disclaimer_title">Update Warning</string>
|
||||
<string name="firmware_update_disclaimer_text">You are about to flash new firmware to your device. This process carries risks.\n\n• Ensure your device is charged.\n• Keep the device close to your phone.\n• Do not close the app during the update.\n\nVerify you have selected the correct firmware for your hardware.</string>
|
||||
|
|
@ -1058,9 +1063,39 @@
|
|||
<string name="firmware_update_flashing">Flashing device, please wait...</string>
|
||||
<string name="firmware_update_method_usb">USB File Transfer</string>
|
||||
<string name="firmware_update_method_ble">BLE OTA</string>
|
||||
<string name="firmware_update_method_wifi">WiFi OTA</string>
|
||||
<string name="firmware_update_method_detail">Update via %1$s</string>
|
||||
<string name="firmware_update_usb_instruction_title">Select DFU USB Drive</string>
|
||||
<string name="firmware_update_usb_instruction_text">Your device has rebooted into DFU mode and should appear as a USB drive (e.g., RAK4631).\n\nWhen the file picker opens, please select the root of that drive to save the firmware file.</string>
|
||||
<string name="firmware_update_verifying">Verifying update...</string>
|
||||
<string name="firmware_update_verification_failed">Verification timed out. Device did not reconnect in time.</string>
|
||||
<string name="firmware_update_waiting_reconnect">Waiting for device to reconnect...</string>
|
||||
<string name="firmware_update_target">Target: %1$s</string>
|
||||
<string name="firmware_update_release_notes">Release Notes</string>
|
||||
<string name="firmware_update_unknown_error">Unknown error</string>
|
||||
<string name="firmware_update_local_failed">Local update failed</string>
|
||||
<string name="firmware_update_dfu_error">DFU Error: %1$s</string>
|
||||
<string name="firmware_update_dfu_aborted">DFU Aborted</string>
|
||||
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
|
||||
<string name="firmware_update_battery_low">Battery too low (%1$d%%). Please charge your device before updating.</string>
|
||||
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
|
||||
<string name="firmware_update_nordic_failed">Nordic DFU Update failed</string>
|
||||
<string name="firmware_update_usb_failed">USB Update failed</string>
|
||||
<string name="firmware_update_hash_rejected">Firmware hash rejected. Device may require hash provisioning or bootloader update.</string>
|
||||
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
|
||||
<string name="firmware_update_loading">Loading firmware...</string>
|
||||
<string name="firmware_update_waiting_reboot">Waiting for device to reboot into OTA mode...</string>
|
||||
<string name="firmware_update_connecting_attempt">Connecting to device (attempt %1$d/%2$d)...</string>
|
||||
<string name="firmware_update_checking_version">Checking device version...</string>
|
||||
<string name="firmware_update_starting_ota">Starting OTA update...</string>
|
||||
<string name="firmware_update_uploading">Uploading firmware...</string>
|
||||
<string name="firmware_update_uploading_progress">Uploading firmware... %1$d%% (%2$s)</string>
|
||||
<string name="firmware_update_rebooting_device">Rebooting device...</string>
|
||||
<string name="firmware_update_channel_name">Firmware Update</string>
|
||||
<string name="firmware_update_channel_description">Firmware update status</string>
|
||||
<string name="firmware_update_erasing">Erasing...</string>
|
||||
<string name="back">Back</string>
|
||||
|
||||
<string name="interval_unset">Unset</string>
|
||||
<string name="interval_always_on">Always On</string>
|
||||
<plurals name="plurals_seconds">
|
||||
|
|
|
|||
|
|
@ -109,3 +109,86 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
|
|||
|
||||
</details>
|
||||
<!--endregion-->
|
||||
|
||||
## Firmware Update System
|
||||
|
||||
The `:feature:firmware` module provides a unified interface for updating Meshtastic devices across different platforms and connection types.
|
||||
|
||||
### Supported Platforms & Methods
|
||||
|
||||
Meshtastic-Android supports three primary firmware update flows:
|
||||
|
||||
#### 1. ESP32 Unified OTA (WiFi & BLE)
|
||||
Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Nordic Semiconductor Kotlin-BLE-Library** for architectural consistency with the rest of the application.
|
||||
|
||||
**Key Features:**
|
||||
- **Pre-shared Hash Verification**: The app sends the firmware SHA256 hash in an initial `AdminMessage` trigger. The device stores this in NVS and verifies the incoming stream against it.
|
||||
- **Connection Retry**: Robust logic to wait for the device to reboot and start the OTA listener.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Android App
|
||||
participant Radio as Mesh Node (Admin)
|
||||
participant OTA as ESP32 OTA Mode
|
||||
|
||||
Note over App: Phase 1: Preparation
|
||||
App->>App: Calculate SHA256 Hash
|
||||
|
||||
Note over App, Radio: Phase 2: Trigger Reboot
|
||||
App->>Radio: AdminMessage (ota_request = mode + hash)
|
||||
Radio->>Radio: Store Hash in NVS & Reboot
|
||||
|
||||
Note over App, OTA: Phase 3: Connection & Update
|
||||
App->>OTA: Connect (TCP:3232 or BLE)
|
||||
App->>OTA: Handshake & Version Check
|
||||
App->>OTA: Start OTA (Size + Hash)
|
||||
loop Streaming
|
||||
App->>OTA: Stream Data Chunks
|
||||
OTA-->>App: ACK
|
||||
end
|
||||
App->>OTA: REBOOT Command
|
||||
```
|
||||
|
||||
#### 2. nRF52 BLE DFU
|
||||
The standard update method for nRF52-based devices (e.g., RAK4631). It leverages the **Nordic Semiconductor DFU library**.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Android App
|
||||
participant Radio as Mesh Node
|
||||
participant DFU as nRF DFU Bootloader
|
||||
|
||||
App->>Radio: Trigger DFU Mode
|
||||
Radio->>Radio: Reboot into Bootloader
|
||||
App->>DFU: Connect via BLE
|
||||
App->>DFU: Initialize DFU Transaction
|
||||
loop Transfer
|
||||
App->>DFU: Stream ZIP Segments
|
||||
DFU-->>App: Progress
|
||||
end
|
||||
DFU->>DFU: Verify, Swap & Reboot
|
||||
```
|
||||
|
||||
#### 3. USB / UF2 (RP2040, nRF52, STM32)
|
||||
For devices supporting USB Mass Storage updates. The app triggers the device into its native bootloader mode, then guides the user to save the UF2 firmware file to the mounted drive.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Android App
|
||||
participant Radio as Mesh Node
|
||||
participant USB as USB Mass Storage
|
||||
|
||||
App->>Radio: rebootToDfu()
|
||||
Radio->>Radio: Mounts as MESH_DRIVE
|
||||
App->>App: Prompt User to Save UF2
|
||||
App->>USB: Write firmware.uf2
|
||||
USB->>USB: Auto-Flash & Reboot
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
- `UpdateHandler.kt`: Entry point for choosing the correct handler.
|
||||
- `Esp32OtaUpdateHandler.kt`: Orchestrates the Unified OTA flow.
|
||||
- `WifiOtaTransport.kt`: Implements the TCP/UDP transport logic for ESP32.
|
||||
- `BleOtaTransport.kt`: Implements the BLE transport logic for ESP32 using the Nordic BLE library.
|
||||
- `FirmwareRetriever.kt`: Handles downloading and extracting firmware assets (ZIP/BIN/UF2).
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ plugins {
|
|||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
alias(libs.plugins.meshtastic.hilt)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> { namespace = "org.meshtastic.feature.firmware" }
|
||||
|
|
@ -73,4 +74,8 @@ dependencies {
|
|||
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ fun SettingsScreen(
|
|||
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
|
||||
val isDfuCapable by settingsViewModel.isDfuCapable.collectAsStateWithLifecycle()
|
||||
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
|
||||
val destNode by viewModel.destNode.collectAsState()
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
var isWaiting by remember { mutableStateOf(false) }
|
||||
|
|
@ -249,7 +249,7 @@ fun SettingsScreen(
|
|||
isManaged = localConfig.security.isManaged,
|
||||
node = destNode,
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
isDfuCapable = isDfuCapable,
|
||||
isOtaCapable = isOtaCapable,
|
||||
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
|
||||
onRouteClick = { route ->
|
||||
isWaiting = true
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.feature.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.icu.text.SimpleDateFormat
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -45,12 +46,14 @@ import org.meshtastic.core.database.DatabaseManager
|
|||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.positionToMeter
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
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.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
|
|
@ -61,7 +64,6 @@ import org.meshtastic.proto.Portnums
|
|||
import java.io.BufferedWriter
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -118,15 +120,22 @@ constructor(
|
|||
val appVersionName
|
||||
get() = buildConfigProvider.versionName
|
||||
|
||||
val isDfuCapable: StateFlow<Boolean> =
|
||||
val isOtaCapable: StateFlow<Boolean> =
|
||||
combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
|
||||
.flatMapLatest { (node, connectionState) ->
|
||||
if (node == null || !connectionState.isConnected()) {
|
||||
flowOf(false)
|
||||
} else if (radioPrefs.isBle() || radioPrefs.isSerial()) {
|
||||
} else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
|
||||
val hwModel = node.user.hwModel.number
|
||||
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
|
||||
flow { emit(hw?.requiresDfu == true) }
|
||||
// Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
|
||||
val capabilities = Capabilities(node.metadata?.firmwareVersion)
|
||||
|
||||
// ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
|
||||
val isEsp32OtaSupported =
|
||||
hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
|
||||
|
||||
flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
|
||||
} else {
|
||||
flowOf(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.settings.radio
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -88,7 +87,7 @@ fun RadioConfigItemList(
|
|||
isManaged: Boolean,
|
||||
node: Node? = null,
|
||||
excludedModulesUnlocked: Boolean = false,
|
||||
isDfuCapable: Boolean = false,
|
||||
isOtaCapable: Boolean = false,
|
||||
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
|
||||
onRouteClick: (Enum<*>) -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
|
|
@ -212,7 +211,7 @@ fun RadioConfigItemList(
|
|||
ManagedMessage()
|
||||
}
|
||||
|
||||
if (isDfuCapable && state.isLocal) {
|
||||
if (isOtaCapable && state.isLocal) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_update_title),
|
||||
leadingIcon = Icons.Rounded.SystemUpdate,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ kotlinx-coroutines-android = "1.10.2"
|
|||
kotlinx-serialization = "1.9.0"
|
||||
ktlint = "1.7.1"
|
||||
kover = "0.9.4"
|
||||
mockk = "1.13.17"
|
||||
|
||||
# Compose Multiplatform
|
||||
compose-multiplatform = "1.10.0"
|
||||
|
|
@ -137,6 +138,7 @@ okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-intercept
|
|||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.3.0" }
|
||||
androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" }
|
||||
junit = { module = "junit:junit", version = "4.13.2" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
|
||||
# Other
|
||||
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue