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:
James Rich 2026-01-14 21:22:30 -06:00 committed by GitHub
parent 6b5dd24249
commit 2a60480bd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3410 additions and 717 deletions

View file

@ -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
}
]
}
}

View file

@ -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
}
}
}

View file

@ -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 }
}

View file

@ -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)
}
}
}

View file

@ -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) {}