diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt index 9ce1a47..3dc4e23 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -60,6 +60,16 @@ class BleRelayManager( private var espVersion: String? = null private var appVersion: String = "" + interface OtaNotifyCallback { + fun onOtaNotify(status: Int, seq: Int) + } + + private var otaNotifyCallback: OtaNotifyCallback? = null + + fun setOtaNotifyCallback(callback: OtaNotifyCallback?) { + otaNotifyCallback = callback + } + init { instance = this } @@ -330,6 +340,25 @@ class BleRelayManager( 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() { diff --git a/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt index a04c93f..f6197a0 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt @@ -1,13 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later 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.util.Log -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -25,12 +20,13 @@ class OtaManager( private const val TAG = "OtaManager" private const val OTA_CHUNK_DATA_SIZE = 248 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_COMPLETE: Byte = 0x10 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_CHUNK_TIMEOUT_MS = 2000L + private const val OTA_FINAL_TIMEOUT_MS = 10000L + private const val MAX_RETRIES = 3 } interface OtaCallback { @@ -58,6 +54,22 @@ class OtaManager( } 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 { withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") } @@ -67,44 +79,97 @@ class OtaManager( startPacket.putInt(fw.size) relayManager.sendOtaData(startPacket.array()) - 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 - } + try { + withTimeout(OTA_START_TIMEOUT_MS) { + while (true) { delay(100); ensureActive() } + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) {} - val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE - var seq = 0 - var offset = 0 + val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE + var seq = 0 + var offset = 0 - while (offset < fw.size) { - val remaining = fw.size - offset - val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE) + while (offset < fw.size) { + val remaining = fw.size - offset + val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE) - val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN) - packet.putInt(seq) - packet.put(fw, offset, chunkSize) + val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN) + packet.putInt(seq) + packet.put(fw, offset, chunkSize) - relayManager.sendOtaData(packet.array()) - Log.d(TAG, "Sent chunk $seq ($chunkSize bytes)") + chunkAckReceived = false + chunkNakReceived = false + var retries = 0 - val pct = ((offset.toFloat() / fw.size.toFloat()) * 100).toInt().coerceAtMost(99) - withContext(Dispatchers.Main) { - callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks") + 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) { + callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks") + } + + offset += chunkSize + seq++ } - offset += chunkSize - seq++ - delay(50) + withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") } + + 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 } }