docs: OTA improvements implementation plan
This commit is contained in:
parent
b1591b7bdb
commit
8a54367e30
1 changed files with 500 additions and 0 deletions
500
docs/superpowers/plans/2026-07-30-ota-improvements.md
Normal file
500
docs/superpowers/plans/2026-07-30-ota-improvements.md
Normal file
|
|
@ -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"
|
||||
```
|
||||
Loading…
Reference in a new issue