feat: OTA notification handling with ACK/NAK retry logic

This commit is contained in:
eddy 2026-07-30 19:14:23 +02:00
commit 475c029512
2 changed files with 133 additions and 39 deletions

View file

@ -60,6 +60,16 @@ class BleRelayManager(
private var espVersion: String? = null private var espVersion: String? = null
private var appVersion: String = "" private var appVersion: String = ""
interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int)
}
private var otaNotifyCallback: OtaNotifyCallback? = null
fun setOtaNotifyCallback(callback: OtaNotifyCallback?) {
otaNotifyCallback = callback
}
init { init {
instance = this instance = this
} }
@ -330,6 +340,25 @@ class BleRelayManager(
handler.post { callbacks.onStatusChanged(BleStatus.CommError) } handler.post { callbacks.onStatusChanged(BleStatus.CommError) }
} }
} }
@SuppressLint("MissingPermission")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val status = value[0].toInt() and 0xFF
val seq = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF)
} else 0
Log.d(TAG, "OTA notify: status=$status seq=$seq")
handler.post { otaNotifyCallback?.onOtaNotify(status, seq) }
}
}
} }
private fun loadState() { private fun loadState() {

View file

@ -1,13 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda package nl.eddydevink.sylda
import android.annotation.SuppressLint
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
@ -25,12 +20,13 @@ class OtaManager(
private const val TAG = "OtaManager" private const val TAG = "OtaManager"
private const val OTA_CHUNK_DATA_SIZE = 248 private const val OTA_CHUNK_DATA_SIZE = 248
private const val OTA_CMD_START: Byte = 0x10 private const val OTA_CMD_START: Byte = 0x10
private const val OTA_CMD_END: Byte = 0x11
private const val OTA_STATUS_ACK: Byte = 0x00 private const val OTA_STATUS_ACK: Byte = 0x00
private const val OTA_STATUS_COMPLETE: Byte = 0x10 private const val OTA_STATUS_COMPLETE: Byte = 0x10
private const val OTA_STATUS_ERROR: Byte = 0x11 private const val OTA_STATUS_ERROR: Byte = 0x11
private const val OTA_CHUNK_TIMEOUT_MS = 5000L
private const val OTA_START_TIMEOUT_MS = 10000L private const val OTA_START_TIMEOUT_MS = 10000L
private const val OTA_CHUNK_TIMEOUT_MS = 2000L
private const val OTA_FINAL_TIMEOUT_MS = 10000L
private const val MAX_RETRIES = 3
} }
interface OtaCallback { interface OtaCallback {
@ -58,6 +54,22 @@ class OtaManager(
} }
val sha256 = MessageDigest.getInstance("SHA-256").digest(fw) val sha256 = MessageDigest.getInstance("SHA-256").digest(fw)
var chunkAckReceived = false
var chunkNakReceived = false
var finalStatusReceived = false
var finalStatus = -1
val notifyCallback = object : BleRelayManager.OtaNotifyCallback {
override fun onOtaNotify(status: Int, seq: Int) {
when (status) {
0x00 -> chunkAckReceived = true
0x01 -> chunkNakReceived = true
0x10 -> { finalStatusReceived = true; finalStatus = 0x10 }
0x11 -> { finalStatusReceived = true; finalStatus = 0x11 }
}
}
}
relayManager.setOtaNotifyCallback(notifyCallback)
try { try {
withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") } withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") }
@ -67,12 +79,11 @@ class OtaManager(
startPacket.putInt(fw.size) startPacket.putInt(fw.size)
relayManager.sendOtaData(startPacket.array()) relayManager.sendOtaData(startPacket.array())
try {
withTimeout(OTA_START_TIMEOUT_MS) { withTimeout(OTA_START_TIMEOUT_MS) {
while (true) { delay(100); ensureActive() } while (true) { delay(100); ensureActive() }
} }
} catch (e: kotlinx.coroutines.TimeoutCancellationException) { } catch (_: kotlinx.coroutines.TimeoutCancellationException) {}
// timeout is expected — ESP doesn't ACK OTA_START, just begins
}
val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE
var seq = 0 var seq = 0
@ -86,25 +97,79 @@ class OtaManager(
packet.putInt(seq) packet.putInt(seq)
packet.put(fw, offset, chunkSize) packet.put(fw, offset, chunkSize)
relayManager.sendOtaData(packet.array()) chunkAckReceived = false
Log.d(TAG, "Sent chunk $seq ($chunkSize bytes)") chunkNakReceived = false
var retries = 0
val pct = ((offset.toFloat() / fw.size.toFloat()) * 100).toInt().coerceAtMost(99) while (retries < MAX_RETRIES) {
relayManager.sendOtaData(packet.array())
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
try {
withTimeout(OTA_CHUNK_TIMEOUT_MS) {
while (!chunkAckReceived && !chunkNakReceived) {
delay(50)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for ACK/NAK on chunk $seq")
}
if (chunkAckReceived) break
retries++
Log.w(TAG, "Chunk $seq NAK/timeout, retry $retries/$MAX_RETRIES")
}
if (!chunkAckReceived) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na $MAX_RETRIES pogingen") }
return@withContext false
}
val pct = ((offset.toFloat() / fw.size.toFloat()) * 99).toInt().coerceAtMost(99)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks") callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
} }
offset += chunkSize offset += chunkSize
seq++ seq++
delay(50)
} }
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") } withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
finalStatusReceived = false
relayManager.sendOtaData(sha256) relayManager.sendOtaData(sha256)
withContext(Dispatchers.Main) { callback.onProgress(100, "Herstart...") } try {
withContext(Dispatchers.Main) { callback.onComplete() } withTimeout(OTA_FINAL_TIMEOUT_MS) {
while (!finalStatusReceived) {
delay(100)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for final OTA status")
}
relayManager.setOtaNotifyCallback(null)
if (finalStatus == 0x10) {
withContext(Dispatchers.Main) {
callback.onProgress(100, "Herstart...")
callback.onComplete()
}
true true
} else {
withContext(Dispatchers.Main) {
callback.onError("OTA mislukt: ESP SHA256 verificatie fout")
}
false
}
} catch (e: Exception) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA fout: ${e.message}") }
false
}
} }
} }