diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 3da5cd2cf..f7c85daad 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -619,6 +619,7 @@ firmware_update_title
firmware_update_unknown_error
firmware_update_unknown_hardware
firmware_update_unknown_release
+firmware_update_unsupported_transport
firmware_update_uploading
firmware_update_usb_bootloader_warning
firmware_update_usb_failed
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index b5b52f9c9..642059bab 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -643,6 +643,7 @@
Unknown error
Unknown hardware model: %1$d
Unknown remote release
+ Firmware updates are not supported over this connection for this device. Connect a different way (e.g. Bluetooth) to update.
Uploading firmware...
%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.
USB Update failed
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
index a206cb636..b70e23d7c 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
@@ -22,6 +22,7 @@ import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.feature.firmware.ota.FirmwareHashUtil
private val KNOWN_ARCHS = setOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
@@ -173,14 +174,41 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
Logger.i { "Manifest resolved OTA firmware: ${otaEntry.name} (${otaEntry.bytes} bytes, md5=${otaEntry.md5})" }
- return retrieveArtifact(
- release = release,
- hardware = hardware,
- onProgress = onProgress,
- fileSuffix = ".bin",
- internalFileExtension = ".bin",
- preferredFilename = otaEntry.name,
- )
+ val artifact =
+ retrieveArtifact(
+ release = release,
+ hardware = hardware,
+ onProgress = onProgress,
+ fileSuffix = ".bin",
+ internalFileExtension = ".bin",
+ preferredFilename = otaEntry.name,
+ ) ?: return null
+
+ // Verify the download against the manifest before handing it to the OTA flow. On mismatch we return null so the
+ // caller falls back to the filename heuristics (exactly like a missing/garbage manifest) — surfacing a bad
+ // download here beats rebooting the device into OTA mode, which tears down the mesh link, only to fail
+ // device-side hash verification mid-flash.
+ return artifact.takeIf { verifyAgainstManifest(it, otaEntry) }
+ }
+
+ /**
+ * Check a downloaded `.bin` against the manifest entry's [FirmwareManifestFile.bytes] and
+ * [FirmwareManifestFile.md5] (each enforced only when the manifest actually carries it). The md5 read is skipped
+ * when the size already fails, avoiding a full re-read of a known-wrong file.
+ */
+ private suspend fun verifyAgainstManifest(artifact: FirmwareArtifact, entry: FirmwareManifestFile): Boolean {
+ val sizeOk = entry.bytes <= 0 || fileHandler.getFileSize(artifact) == entry.bytes
+ val md5Ok =
+ !sizeOk ||
+ entry.md5.isBlank() ||
+ FirmwareHashUtil.calculateMd5Hex(fileHandler.readBytes(artifact)).equals(entry.md5, ignoreCase = true)
+ if (!sizeOk || !md5Ok) {
+ Logger.w {
+ "Manifest integrity check failed for ${entry.name} " +
+ "(expected ${entry.bytes} bytes, md5=${entry.md5}; sizeOk=$sizeOk, md5Ok=$md5Ok)"
+ }
+ }
+ return sizeOk && md5Ok
}
// ── Private helpers ──────────────────────────────────────────────────────
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
index d85d0acd6..1a60dcc7e 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
@@ -120,6 +120,7 @@ import org.meshtastic.core.resources.firmware_update_taking_a_while
import org.meshtastic.core.resources.firmware_update_target
import org.meshtastic.core.resources.firmware_update_title
import org.meshtastic.core.resources.firmware_update_unknown_release
+import org.meshtastic.core.resources.firmware_update_unsupported_transport
import org.meshtastic.core.resources.firmware_update_usb_bootloader_warning
import org.meshtastic.core.resources.firmware_update_usb_instruction_text
import org.meshtastic.core.resources.firmware_update_usb_instruction_title
@@ -374,6 +375,13 @@ private fun ReadyState(
selectedReleaseType: FirmwareReleaseType,
actions: FirmwareUpdateActions,
) {
+ // Unknown == no update path for this transport+device combo (e.g. ESP32 over Serial, nRF52 over TCP). Say so
+ // up front instead of offering a button whose handler would only throw on press.
+ if (state.updateMethod is FirmwareUpdateMethod.Unknown) {
+ UnsupportedTransportState()
+ return
+ }
+
var showDisclaimer by remember { mutableStateOf(false) }
val device = state.deviceHardware
val haptic = LocalHapticFeedback.current
@@ -455,6 +463,26 @@ private fun ReadyState(
}
}
+@Composable
+private fun UnsupportedTransportState() {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(Modifier.height(16.dp))
+ Icon(
+ MeshtasticIcons.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ stringResource(Res.string.firmware_update_unsupported_transport),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
@Composable
internal fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
MeshtasticDialog(
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 7334a7af9..98681d861 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -188,7 +188,14 @@ class FirmwareUpdateViewModel(
radioPrefs.isBle() -> FirmwareUpdateMethod.Ble
- radioPrefs.isTcp() -> FirmwareUpdateMethod.Wifi
+ radioPrefs.isTcp() -> {
+ // WiFi OTA is ESP32-only; nRF52/RP2040 have no TCP update path.
+ if (deviceHardware.isEsp32Arc) {
+ FirmwareUpdateMethod.Wifi
+ } else {
+ FirmwareUpdateMethod.Unknown
+ }
+ }
else -> FirmwareUpdateMethod.Unknown
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
index 39d6dc6c8..8e0bd6d28 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
@@ -151,19 +151,16 @@ class BleOtaTransport(
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result = safeCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
- val packetsSent = sendCommand(command)
+ sendCommand(command)
+ // Drive on response *type*, never a fragment/response count: the handshake completes only on an explicit OK.
+ // ERASING is an interim progress notification the device may emit before OK, so it just continues the wait. At
+ // a low MTU the command splits into multiple writes; gating completion on a write count would desync against
+ // the device's single OK — the same fragment-count bug PR #5915 removed from streamFirmware.
var handshakeComplete = false
- var responsesReceived = 0
while (!handshakeComplete) {
- val response = waitForResponse(ERASING_TIMEOUT)
- responsesReceived++
- when (val parsed = OtaResponse.parse(response)) {
- is OtaResponse.Ok -> {
- if (responsesReceived >= packetsSent) {
- handshakeComplete = true
- }
- }
+ when (val parsed = OtaResponse.parse(waitForResponse(ERASING_TIMEOUT))) {
+ is OtaResponse.Ok -> handshakeComplete = true
is OtaResponse.Erasing -> {
Logger.i { "BLE OTA: Device erasing flash..." }
@@ -177,9 +174,7 @@ class BleOtaTransport(
throw OtaProtocolException.CommandFailed(command, parsed)
}
- else -> {
- Logger.w { "BLE OTA: Unexpected handshake response: $response" }
- }
+ else -> Logger.w { "BLE OTA: Unexpected handshake response: $parsed" }
}
}
}
@@ -205,6 +200,11 @@ class BleOtaTransport(
onProgress: suspend (Float) -> Unit,
): Result = safeCatching {
val totalBytes = data.size
+ if (totalBytes == 0) {
+ // Fail now: an empty image would otherwise skip the loop and then wait out the full
+ // VERIFICATION_TIMEOUT before failing.
+ throw OtaProtocolException.TransferFailed("Firmware is empty")
+ }
var sentBytes = 0
val writePayload = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) ?: SAFE_WRITE_PAYLOAD
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
index bc48c3455..530dfc54c 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
@@ -62,6 +62,12 @@ private const val PACKET_SEND_DELAY_MS = 2000L
// Time to wait for BLE GATT to fully release after disconnecting mesh service
private const val GATT_RELEASE_DELAY_MS = 1000L
+// A BLE target is a 6-octet MAC (e.g. AA:BB:CC:DD:EE:FF). Matching the exact MAC shape — not merely "contains a colon"
+// — keeps an IPv6 WiFi target (which also has colons) from being misrouted to the BLE path.
+private val MAC_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
+
+internal fun isBleMacAddress(target: String): Boolean = MAC_ADDRESS_REGEX.matches(target)
+
/**
* KMP handler for ESP32 firmware updates using the Unified OTA protocol. Supports both BLE and WiFi/TCP transports via
* [UnifiedOtaProtocol].
@@ -80,14 +86,14 @@ class Esp32OtaUpdateHandler(
private val dispatchers: CoroutineDispatchers,
) : FirmwareUpdateHandler {
- /** Entry point for FirmwareUpdateHandler interface. Routes to BLE (MAC with colons) or WiFi (IP without). */
+ /** Entry point for FirmwareUpdateHandler interface. Routes to BLE (target is a MAC) or WiFi (anything else). */
override suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
target: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri?,
- ): FirmwareArtifact? = if (target.contains(":")) {
+ ): FirmwareArtifact? = if (isBleMacAddress(target)) {
startBleUpdate(release, hardware, target, updateState, firmwareUri)
} else {
startWifiUpdate(release, hardware, target, updateState, firmwareUri)
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt
index e7d47103e..8df11c037 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt
@@ -31,4 +31,7 @@ object FirmwareHashUtil {
/** Convert byte array to lowercase hex string. */
fun bytesToHex(bytes: ByteArray): String = bytes.toByteString().hex()
+
+ /** Calculate the MD5 hex digest of raw bytes — used to verify a download against a firmware manifest's `md5`. */
+ fun calculateMd5Hex(data: ByteArray): String = data.toByteString().md5().hex()
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt
index 0fedf9ee4..6881c83f5 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt
@@ -51,6 +51,16 @@ internal fun parseDfuZipEntries(entries: Map): DfuZipPackage
val entry =
manifest.manifest.primaryEntry ?: throw DfuException.InvalidPackage("No firmware entry found in manifest.json")
+ // ponytail: app-only Meshtastic OTA zips carry a single image, so we transfer only the primary. A combined
+ // app+SoftDevice+bootloader package would need sequential DFU sessions we don't implement — warn loudly rather
+ // than silently flash one image of several. Upgrade path: multi-image sequencing if such packages ever ship.
+ if (manifest.manifest.imageCount > 1) {
+ Logger.w {
+ "DFU: package declares ${manifest.manifest.imageCount} images; flashing only the primary " +
+ "(${entry.binFile}). Combined app+SoftDevice+bootloader packages are not supported."
+ }
+ }
+
val initPacket =
entries[entry.datFile] ?: throw DfuException.InvalidPackage("Init packet '${entry.datFile}' not found in zip")
val firmware =
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt
index 1f3f8e261..f47627df5 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt
@@ -268,6 +268,10 @@ internal data class DfuManifestContent(
/** First non-null entry in priority order. */
val primaryEntry: DfuManifestEntry?
get() = application ?: softdeviceBootloader ?: bootloader ?: softdevice
+
+ /** Number of image entries present. >1 means a combined package, of which only [primaryEntry] is flashed. */
+ val imageCount: Int
+ get() = listOfNotNull(application, softdeviceBootloader, bootloader, softdevice).size
}
@Serializable
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
index 277e0c200..b662c0088 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
@@ -210,10 +210,12 @@ class SecureDfuTransport(
}
}
} catch (_: TimeoutCancellationException) {
- Logger.w {
- "DFU: $logLabel buttonless trigger timed out — likely cause: stale BLE bond (Meshtastic " +
- "BLEDfu requires SECMODE_ENC_WITH_MITM). User must Forget+Re-pair the device in Android " +
- "Bluetooth settings if the next DFU-mode scan also fails."
+ // Expected on the normal success path: the WITH_RESPONSE write never ATT-ACKs because the device reboots
+ // into the bootloader before sending it. connectToDfuMode()'s scan confirms whether the trigger actually
+ // landed; only that scan failing indicates a real problem (e.g. a stale bond that blocked the trigger),
+ // and it surfaces the Forget+Re-pair guidance there.
+ Logger.d {
+ "DFU: $logLabel buttonless trigger write did not ACK before timeout (expected — device rebooting)"
}
}
}
@@ -233,7 +235,11 @@ class SecureDfuTransport(
val device =
scanForDevice { d -> d.address in targetAddresses }
- ?: throw DfuException.ConnectionFailed("DFU mode device not found. Tried: $targetAddresses")
+ ?: throw DfuException.ConnectionFailed(
+ "DFU mode device not found (tried $targetAddresses). If the device never rebooted into DFU mode, " +
+ "a stale BLE bond may be blocking the trigger (Meshtastic BLEDfu requires " +
+ "SECMODE_ENC_WITH_MITM) — Forget+Re-pair the device in Android Bluetooth settings and retry.",
+ )
Logger.i { "DFU: Found DFU mode device at ${device.address}, connecting..." }
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt
index 9410dcaaa..19407d1a5 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.feature.firmware.ota.FirmwareHashUtil
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@@ -90,6 +91,66 @@ abstract class CommonFirmwareRetrieverTest {
assertEquals("firmware-heltec-v3-2.7.17.bin", result.fileName)
}
+ @Test
+ fun `retrieveEsp32Firmware accepts manifest artifact when md5 and size match`() = runTest {
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+
+ // Distinctive app0 name no fallback heuristic would construct, so resolving it proves the verified manifest
+ // path returned the artifact (rather than a filename-heuristic fallback that happens to share the name).
+ val name = "firmware-heltec-v3-2.7.17-app0.bin"
+ val payload = ByteArray(2048) { it.toByte() }
+ val md5 = FirmwareHashUtil.calculateMd5Hex(payload)
+ handler.textResponses["$BASE_URL/firmware-2.7.17/firmware-heltec-v3-2.7.17.mt.json"] =
+ """{"files":[{"name":"$name","part_name":"app0","md5":"$md5","bytes":${payload.size}}]}"""
+ handler.existingUrls.add("$BASE_URL/firmware-2.7.17/$name")
+ handler.fileBytes[name] = payload
+
+ val result = retriever.retrieveEsp32Firmware(TEST_RELEASE, TEST_HARDWARE) {}
+
+ assertNotNull(result, "Manifest artifact passing the md5+size check should resolve")
+ assertEquals(name, result.fileName)
+ }
+
+ @Test
+ fun `retrieveEsp32Firmware rejects manifest artifact on md5 mismatch and falls back`() = runTest {
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+
+ val name = "firmware-heltec-v3-2.7.17-app0.bin"
+ // Size matches (4) so md5 is computed; the manifest md5 is wrong, so the artifact must be rejected.
+ handler.textResponses["$BASE_URL/firmware-2.7.17/firmware-heltec-v3-2.7.17.mt.json"] =
+ """{"files":[{"name":"$name","part_name":"app0","md5":"deadbeef","bytes":4}]}"""
+ handler.existingUrls.add("$BASE_URL/firmware-2.7.17/$name")
+ handler.fileBytes[name] = byteArrayOf(0x01, 0x02, 0x03, 0x04)
+ // Heuristic fallback the rejection should land on.
+ handler.existingUrls.add("$BASE_URL/firmware-2.7.17/firmware-heltec-v3-2.7.17.bin")
+
+ val result = retriever.retrieveEsp32Firmware(TEST_RELEASE, TEST_HARDWARE) {}
+
+ assertNotNull(result)
+ assertEquals("firmware-heltec-v3-2.7.17.bin", result.fileName)
+ }
+
+ @Test
+ fun `retrieveEsp32Firmware rejects manifest artifact on size mismatch and falls back`() = runTest {
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+
+ val name = "firmware-heltec-v3-2.7.17-app0.bin"
+ // Blank md5 → only size is checked; the declared size disagrees with the download, so it must be rejected.
+ handler.textResponses["$BASE_URL/firmware-2.7.17/firmware-heltec-v3-2.7.17.mt.json"] =
+ """{"files":[{"name":"$name","part_name":"app0","md5":"","bytes":9999}]}"""
+ handler.existingUrls.add("$BASE_URL/firmware-2.7.17/$name")
+ handler.fileBytes[name] = ByteArray(10)
+ handler.existingUrls.add("$BASE_URL/firmware-2.7.17/firmware-heltec-v3-2.7.17.bin")
+
+ val result = retriever.retrieveEsp32Firmware(TEST_RELEASE, TEST_HARDWARE) {}
+
+ assertNotNull(result)
+ assertEquals("firmware-heltec-v3-2.7.17.bin", result.fileName)
+ }
+
@Test
fun `retrieveEsp32Firmware falls back to current naming when manifest unavailable`() = runTest {
val handler = FakeFirmwareFileHandler()
@@ -345,6 +406,9 @@ abstract class CommonFirmwareRetrieverTest {
/** Result returned by [extractFirmwareFromZip]. */
var zipExtractionResult: FirmwareArtifact? = null
+ /** Bytes (and thus size) reported by [readBytes] / [getFileSize] for a downloaded file, keyed by fileName. */
+ val fileBytes = mutableMapOf()
+
// Tracking
val checkedUrls = mutableListOf()
val fetchedTextUrls = mutableListOf()
@@ -401,9 +465,10 @@ abstract class CommonFirmwareRetrieverTest {
preferredFilename: String?,
): FirmwareArtifact? = zipExtractionResult
- override suspend fun getFileSize(file: FirmwareArtifact): Long = 0L
+ override suspend fun getFileSize(file: FirmwareArtifact): Long = (fileBytes[file.fileName]?.size ?: 0).toLong()
- override suspend fun readBytes(artifact: FirmwareArtifact): ByteArray = ByteArray(0)
+ override suspend fun readBytes(artifact: FirmwareArtifact): ByteArray =
+ fileBytes[artifact.fileName] ?: ByteArray(0)
override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = null
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index a19b8e455..1c4e7300b 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -369,6 +369,23 @@ class FirmwareUpdateViewModelTest {
assertIs(state.updateMethod)
}
+ @Test
+ fun `update method is Unknown for TCP nrf52`() = runTest {
+ // WiFi OTA is ESP32-only — nRF52 over TCP has no update path, so the method must be Unknown (the screen then
+ // shows an unsupported message instead of an Update button that would only throw on press).
+ val hardware = DeviceHardware(hwModel = 1, architecture = "nrf52", platformioTarget = "tbeam")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
+ Result.success(hardware)
+ every { radioPrefs.devAddr } returns MutableStateFlow("t192.168.1.1")
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.state.value
+ assertIs(state)
+ assertIs(state.updateMethod)
+ }
+
@Test
fun `setReleaseType LOCAL produces null release in Ready`() = runTest {
advanceUntilIdle()
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt
index d52d35e95..73e487402 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt
@@ -422,6 +422,21 @@ class BleOtaTransportTest {
assertIs(result.exceptionOrNull())
}
+ @Test
+ fun `streamFirmware fails immediately on empty firmware`() = runTest {
+ val scanner = FakeBleScanner()
+ val connection = FakeBleConnection()
+ val (transport) = createTransport(scanner, connection)
+ connectTransport(transport, scanner, connection)
+
+ // Empty image: must fail right away rather than skipping the loop and waiting out VERIFICATION_TIMEOUT. No
+ // device response is buffered, so a regression (waiting for a response) would surface as a Timeout, not this.
+ val result = transport.streamFirmware(ByteArray(0), 512) {}
+
+ assertTrue(result.isFailure)
+ assertIs(result.exceptionOrNull())
+ }
+
// -----------------------------------------------------------------------
// close()
// -----------------------------------------------------------------------
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaRoutingTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaRoutingTest.kt
new file mode 100644
index 000000000..6d9fa4943
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaRoutingTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 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 .
+ */
+package org.meshtastic.feature.firmware.ota
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/** Verifies BLE-vs-WiFi target routing in [Esp32OtaUpdateHandler] — specifically that IPv6 literals route to WiFi. */
+class Esp32OtaRoutingTest {
+
+ @Test
+ fun `MAC address is a BLE target`() {
+ assertTrue(isBleMacAddress("AA:BB:CC:DD:EE:FF"))
+ assertTrue(isBleMacAddress("00:11:22:33:44:55"))
+ }
+
+ @Test
+ fun `IPv4 address is not a BLE target`() {
+ assertFalse(isBleMacAddress("192.168.1.100"))
+ }
+
+ @Test
+ fun `IPv6 literal is not a BLE target`() {
+ // The bug this guards: an IPv6 literal carries colons but must route to WiFi, not be mistaken for a MAC.
+ assertFalse(isBleMacAddress("fe80::1"))
+ assertFalse(isBleMacAddress("2001:db8::ff00:42:8329"))
+ }
+
+ @Test
+ fun `hostname is not a BLE target`() {
+ assertFalse(isBleMacAddress("meshtastic.local"))
+ }
+}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt
index 6fb5d25c3..f76f2f0b2 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt
@@ -124,4 +124,34 @@ class DfuZipParserTest {
val ex = assertFailsWith { parseDfuZipEntries(entries) }
assertEquals("Firmware 'app.bin' not found in zip", ex.message)
}
+
+ @Test
+ fun picksPriorityImageWhenMultiplePresent() {
+ // Combined packages are unsupported; the parser flashes only the priority (application) image, ignoring the
+ // softdevice entry. Guards the multi-image limitation documented in parseDfuZipEntries.
+ val manifestJson =
+ """
+ {
+ "manifest": {
+ "application": { "bin_file": "app.bin", "dat_file": "app.dat" },
+ "softdevice": { "bin_file": "sd.bin", "dat_file": "sd.dat" }
+ }
+ }
+ """
+ .trimIndent()
+
+ val entries =
+ mapOf(
+ "manifest.json" to manifestJson.encodeToByteArray(),
+ "app.bin" to byteArrayOf(0x01, 0x02),
+ "app.dat" to byteArrayOf(0x03),
+ "sd.bin" to byteArrayOf(0x04),
+ "sd.dat" to byteArrayOf(0x05),
+ )
+
+ val packageResult = parseDfuZipEntries(entries)
+
+ assertTrue(packageResult.firmware.contentEquals(byteArrayOf(0x01, 0x02)))
+ assertTrue(packageResult.initPacket.contentEquals(byteArrayOf(0x03)))
+ }
}