feat: OTA notification handling with ACK/NAK retry logic
This commit is contained in:
parent
ea8bab4571
commit
475c029512
2 changed files with 133 additions and 39 deletions
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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,44 +79,97 @@ class OtaManager(
|
||||||
startPacket.putInt(fw.size)
|
startPacket.putInt(fw.size)
|
||||||
relayManager.sendOtaData(startPacket.array())
|
relayManager.sendOtaData(startPacket.array())
|
||||||
|
|
||||||
withTimeout(OTA_START_TIMEOUT_MS) {
|
try {
|
||||||
while (true) { delay(100); ensureActive() }
|
withTimeout(OTA_START_TIMEOUT_MS) {
|
||||||
}
|
while (true) { delay(100); ensureActive() }
|
||||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
}
|
||||||
// timeout is expected — ESP doesn't ACK OTA_START, just begins
|
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
var offset = 0
|
var offset = 0
|
||||||
|
|
||||||
while (offset < fw.size) {
|
while (offset < fw.size) {
|
||||||
val remaining = fw.size - offset
|
val remaining = fw.size - offset
|
||||||
val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE)
|
val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE)
|
||||||
|
|
||||||
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
|
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
|
||||||
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) {
|
||||||
withContext(Dispatchers.Main) {
|
relayManager.sendOtaData(packet.array())
|
||||||
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
|
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) {
|
||||||
|
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += chunkSize
|
||||||
|
seq++
|
||||||
}
|
}
|
||||||
|
|
||||||
offset += chunkSize
|
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
|
||||||
seq++
|
|
||||||
delay(50)
|
finalStatusReceived = false
|
||||||
|
relayManager.sendOtaData(sha256)
|
||||||
|
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
} 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
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
|
|
||||||
|
|
||||||
relayManager.sendOtaData(sha256)
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) { callback.onProgress(100, "Herstart...") }
|
|
||||||
withContext(Dispatchers.Main) { callback.onComplete() }
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue