Project_SylDa/docs/superpowers/plans/2026-07-30-ota-improvements.md

16 KiB

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):

        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
pio run
  • Step 3: Commit
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:

    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):

        @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:

    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
./gradlew assembleDebug
  • Step 5: Commit
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:

    NimBLEDevice::setMTU(512);
    NimBLEDevice::init(deviceName);
  • Step 2: ESP — compileer
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):

    private var negotiatedMtu: Int = 23

In onMtuChanged():

        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):

    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:

        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):

    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
./gradlew assembleDebug
  • Step 6: Commit
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:

    @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:

                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
./gradlew assembleDebug
  • Step 4: Commit
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
./gradlew assembleDebug

Bevestig met grep dat er nergens meer otaStatusText voorkomt:

grep -r "otaStatusText" android/

Output moet leeg zijn.

  • Step 3: Commit
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
pio run

Expected: SUCCESS

  • Step 2: Android app compileren
./gradlew assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 3: ESP flashen via USB
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)
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"