From 675504cbf5d08c80094710181084d84a5eba547d Mon Sep 17 00:00:00 2001 From: eddy Date: Thu, 30 Jul 2026 17:14:27 +0200 Subject: [PATCH] feat: add OtaManager for BLE firmware update --- .../java/nl/eddydevink/sylda/OtaManager.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt diff --git a/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt new file mode 100644 index 0000000..07b0050 --- /dev/null +++ b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt @@ -0,0 +1,110 @@ +// 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 +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +class OtaManager( + private val context: Context, + private val relayManager: BleRelayManager +) { + companion object { + private const val TAG = "OtaManager" + private const val OTA_CHUNK_DATA_SIZE = 500 + 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 + } + + interface OtaCallback { + fun onProgress(percent: Int, message: String) + fun onComplete() + fun onError(message: String) + } + + private var firmwareData: ByteArray? = null + + fun loadFirmware(): Boolean { + return try { + firmwareData = context.assets.open("firmware/firmware.bin").readBytes() + true + } catch (e: Exception) { + Log.e(TAG, "Failed to load firmware", e) + false + } + } + + suspend fun performOta(callback: OtaCallback): Boolean = withContext(Dispatchers.IO) { + val fw = firmwareData ?: run { + withContext(Dispatchers.Main) { callback.onError("Firmware niet geladen") } + return@withContext false + } + + val sha256 = MessageDigest.getInstance("SHA-256").digest(fw) + + try { + withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") } + + val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN) + startPacket.put(OTA_CMD_START) + 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 + } + + 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) + + 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)") + + val pct = ((offset.toFloat() / fw.size.toFloat()) * 100).toInt().coerceAtMost(99) + withContext(Dispatchers.Main) { + callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks") + } + + offset += chunkSize + seq++ + delay(50) + } + + 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 + } +}