From 8a54367e30c64ca60141ee3ff0872b1ee4becf8f Mon Sep 17 00:00:00 2001 From: eddy Date: Thu, 30 Jul 2026 19:08:18 +0200 Subject: [PATCH] docs: OTA improvements implementation plan --- .../plans/2026-07-30-ota-improvements.md | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-ota-improvements.md diff --git a/docs/superpowers/plans/2026-07-30-ota-improvements.md b/docs/superpowers/plans/2026-07-30-ota-improvements.md new file mode 100644 index 0000000..27fb35e --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-ota-improvements.md @@ -0,0 +1,500 @@ +# OTA Improvements — Implementatieplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Zes code review aanbevelingen implementeren: SHA256 verificatie op ESP, OTA notification handling op Android, dynamische chunk size, writeCharacteristic return check, otaStatusText verwijderen. + +**Architecture:** ESP stuurt al ACK/NAK/COMPLETE/ERROR notificaties — Android moet ze alleen nog verwerken. Chunk size wordt dynamisch afgeleid van onderhandelde MTU (512 via `setMTU`). SHA256 verificatie voorkomt dat corrupte firmware de boot partitie overschrijft. + +**Tech Stack:** C++ (ESP32, NimBLE, esp_ota_ops, mbedtls), Kotlin (Android, BLE GATT, Coroutines) + +**Verify:** Na elke task: compilatie (`pio run` voor ESP, `./gradlew assembleDebug` voor Android). Geen automated tests — handmatig flashen naar ESP en OTA testen na alle tasks. + +--- + +### Task 1: ESP SHA256 verificatie + +**Files:** +- Modify: `src/main.cpp:192-213` + +De ESP ontvangt al de SHA256 hash als laatste chunk (32 bytes, opgeslagen in `otaExpectedSha`) en roept `esp_ota_end()` aan. Wat ontbreekt: `esp_ota_get_app_elf_sha256()` + `memcmp()` vóór `esp_ota_set_boot_partition()`. + +- [ ] **Step 1: Vervang CMD_END handler met SHA256 verificatie** + +In `src/main.cpp`, vervang de bestaande 32-byte handler (regels 192-213): + +```cpp + if (len == 32 && otaInProgress) { + memcpy(otaExpectedSha, data, 32); + esp_err_t err = esp_ota_end(otaHandle); + if (err != ESP_OK) { + Serial.printf("OTA: end failed (%d)\n", err); + otaReset(); + sendOtaNotify(pChar, OTA_STATUS_ERROR, 0); + return; + } + uint8_t computedSha[32]; + err = esp_ota_get_app_elf_sha256(otaHandle, (char *)computedSha, sizeof(computedSha)); + if (err != ESP_OK) { + Serial.printf("OTA: SHA256 read failed (%d)\n", err); + otaReset(); + sendOtaNotify(pChar, OTA_STATUS_ERROR, 0); + return; + } + if (memcmp(computedSha, otaExpectedSha, 32) != 0) { + Serial.println("OTA: SHA256 mismatch — aborting"); + otaReset(); + sendOtaNotify(pChar, OTA_STATUS_ERROR, 0); + return; + } + err = esp_ota_set_boot_partition(otaPartition); + if (err != ESP_OK) { + Serial.printf("OTA: set boot partition failed (%d)\n", err); + otaReset(); + sendOtaNotify(pChar, OTA_STATUS_ERROR, 0); + return; + } + digitalWrite(LED_PIN, HIGH); + sendOtaNotify(pChar, OTA_STATUS_COMPLETE, 0); + Serial.println("OTA: SHA256 OK, rebooting..."); + delay(500); + esp_restart(); + return; + } +``` + +De wijziging: na `esp_ota_end()` wordt `esp_ota_get_app_elf_sha256()` aangeroepen en wordt `memcmp` gedaan. Bij mismatch: ERROR notificatie + `otaReset()` zonder boot partitie te wijzigen. + +- [ ] **Step 2: Compileer ESP firmware** + +```bash +pio run +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/main.cpp +git commit -m "feat: add SHA256 verification before esp_ota_set_boot_partition" +``` + +--- + +### Task 2: Android OTA notification handling + +**Files:** +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt` +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt` + +ESP stuurt 5-byte notificaties `[status:1][seq:4_BE]` met status `0x00`=ACK, `0x01`=NAK, `0x10`=COMPLETE, `0x11`=ERROR. Android moet deze verwerken met retry-logica. + +- [ ] **Step 1: Callback interface toevoegen voor OTA notificaties** + +In `BleRelayManager.kt`, voeg een callback interface toe voor OTA notificaties rond de `BleCallbacks` interface (regel 64): + +In `BleRelayManager.kt`, voeg toe na de bestaande `BleCallbacks` interface: + +```kotlin + interface OtaNotifyCallback { + fun onOtaNotify(status: Int, seq: Int) + } + + private var otaNotifyCallback: OtaNotifyCallback? = null + + fun setOtaNotifyCallback(callback: OtaNotifyCallback?) { + otaNotifyCallback = callback + } +``` + +- [ ] **Step 2: `onCharacteristicChanged()` toevoegen aan `GattCallback`** + +In `BleRelayManager.kt`, voeg `onCharacteristicChanged()` toe in de `GattCallback` class, na `onCharacteristicWrite()` (regel 327): + +```kotlin + @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) } + } + } +``` + +- [ ] **Step 3: Retry-logica in `OtaManager.kt`** + +Herschrijf `performOta()` in `OtaManager.kt` (regels 54-109) om te wachten op ACK/NAK per chunk: + +**Replace de hele `performOta()` method:** + +```kotlin + 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) + 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...") } + + val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN) + startPacket.put(OTA_CMD_START) + startPacket.putInt(fw.size) + relayManager.sendOtaData(startPacket.array()) + + 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 + + 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) + + chunkAckReceived = false + chunkNakReceived = false + var retries = 0 + + while (retries < 3) { + relayManager.sendOtaData(packet.array()) + Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}") + + try { + withTimeout(2000L) { + 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/3") + } + + if (!chunkAckReceived) { + relayManager.setOtaNotifyCallback(null) + withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na 3 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++ + } + + withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") } + + finalStatusReceived = false + relayManager.sendOtaData(sha256) + + try { + withTimeout(10000L) { + 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 + } + } +``` + +- [ ] **Step 4: Compileer Android app** + +```bash +./gradlew assembleDebug +``` + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt +git commit -m "feat: OTA notification handling with ACK/NAK retry logic" +``` + +--- + +### Task 3: ESP setMTU(512) + Android dynamische chunk size + +**Files:** +- Modify: `src/main.cpp` (BLE setup, rond regel 270) +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt` +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt` + +- [ ] **Step 1: ESP — zet MTU naar 512** + +In `src/main.cpp`, in de `setup()` functie, voeg `NimBLEDevice::setMTU(512)` toe vóór de BLE initialisatie. Zoek `NimBLEDevice::init` (rond regel 275) en voeg de regel ervoor toe: + +```cpp + NimBLEDevice::setMTU(512); + NimBLEDevice::init(deviceName); +``` + +- [ ] **Step 2: ESP — compileer** + +```bash +pio run +``` + +- [ ] **Step 3: Android — MTU opslaan en doorgeven** + +In `BleRelayManager.kt`, `onMtuChanged()` (regel 265), sla de MTU op: + +Voeg een field toe bij de andere fields (rond regel 59): + +```kotlin + private var negotiatedMtu: Int = 23 +``` + +In `onMtuChanged()`: + +```kotlin + override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) { + Log.d(TAG, "MTU changed to $mtu") + negotiatedMtu = mtu + val service = gatt.getService(SERVICE_UUID) + ... +``` + +Voeg getter toe (na `getEspVersion()`, regel 220): + +```kotlin + fun getNegotiatedMtu(): Int = negotiatedMtu +``` + +- [ ] **Step 4: Android — dynamische chunk size in `OtaManager.kt`** + +Vervang `OTA_CHUNK_DATA_SIZE` constant (regel 26) door een dynamische property: + +```kotlin + private val OTA_CHUNK_DATA_SIZE: Int + get() { + val mtu = instance?.relayManager?.getNegotiatedMtu() ?: 23 + return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK) + } + private const val OTA_ESP_MAX_CHUNK = 500 +``` + +Maar `instance?.relayManager` werkt niet direct in companion. Maak `OtaManager` non-companion eigenschap of sla de chunk size op bij constructie: + +In `OtaManager` class body (na companion object, voor `loadFirmware`): + +```kotlin + private var chunkDataSize: Int = 248 + + init { + chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu()) + } + + private fun calculateChunkSize(mtu: Int): Int { + return minOf(maxOf(mtu - 3 - 4, 20), 500) + } +``` + +Vervang alle referenties naar `OTA_CHUNK_DATA_SIZE` in `performOta()` naar `chunkDataSize` (komt 3x voor: regel 77, 83, en een impliciete in de chunkSize berekening). + +- [ ] **Step 5: Android — compileer** + +```bash +./gradlew assembleDebug +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/main.cpp android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt +git commit -m "feat: ESP MTU 512 + dynamic Android chunk size" +``` + +--- + +### Task 4: writeCharacteristic return check + +**Files:** +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt:186-192` +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt` + +- [ ] **Step 1: `sendOtaData()` return waarde toevoegen** + +In `BleRelayManager.kt`, wijzig `sendOtaData()` (regel 186) om `Boolean` terug te geven: + +```kotlin + @SuppressLint("MissingPermission") + fun sendOtaData(data: ByteArray): Boolean { + val char = otaCharacteristic ?: return false + val gatt = bluetoothGatt ?: return false + char.value = data + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + return gatt.writeCharacteristic(char) + } +``` + +- [ ] **Step 2: Return check in `performOta()`** + +In `OtaManager.kt`, in de retry-loop van `performOta()` (de while-loop die chunks stuurt), wijzig de `relayManager.sendOtaData(packet.array())` aanroep: + +```kotlin + while (retries < 3) { + val writeOk = relayManager.sendOtaData(packet.array()) + if (!writeOk) { + Log.w(TAG, "writeCharacteristic returned false for chunk $seq, retrying") + retries++ + delay(100) + continue + } + Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}") + ... +``` + +- [ ] **Step 3: Compileer** + +```bash +./gradlew assembleDebug +``` + +- [ ] **Step 4: Commit** + +```bash +git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt +git commit -m "fix: check writeCharacteristic return value in sendOtaData" +``` + +--- + +### Task 5: otaStatusText widget verwijderen + +**Files:** +- Modify: `android/app/src/main/res/layout/activity_settings.xml:39-47` + +- [ ] **Step 1: Verwijder `otaStatusText` uit layout** + +In `activity_settings.xml`, verwijder regels 39-47 (de `TextView` met id `otaStatusText`). + +- [ ] **Step 2: Compileer en verifieer dat er geen referenties meer zijn** + +```bash +./gradlew assembleDebug +``` + +Bevestig met grep dat er nergens meer `otaStatusText` voorkomt: + +```bash +grep -r "otaStatusText" android/ +``` + +Output moet leeg zijn. + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/res/layout/activity_settings.xml +git commit -m "chore: remove unused otaStatusText widget" +``` + +--- + +### Task 6: Eindverificatie — alles compileren en OTA testen + +- [ ] **Step 1: ESP firmware compileren** + +```bash +pio run +``` + +Expected: SUCCESS + +- [ ] **Step 2: Android app compileren** + +```bash +./gradlew assembleDebug +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: ESP flashen via USB** + +```bash +pio run -t upload +``` + +- [ ] **Step 4: OTA testen via BLE** + +1. Open app op telefoon, verbind met SylDa_Server +2. Ga naar Settings, selecteer Bluetooth +3. Klik "Module bijwerken" +4. Verify: progress bar vult, geen foutmeldingen +5. Verify: ESP reboot en toont v5 in versie + +- [ ] **Step 5: Commit firmware.bin (indien build hash veranderd)** + +```bash +cp .pio/build/esp32-s3-devkitc-1/firmware.bin android/app/src/main/assets/firmware/firmware.bin +git add android/app/src/main/assets/firmware/firmware.bin +git commit -m "chore: update bundled firmware.bin" +```