26 KiB
BLE OTA Firmware Updates — Implementation Plan
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: Enable firmware updates over BLE from the Android app, with automatic version check on connect.
Architecture: ESP32 exposes a firmware version READ characteristic and an OTA WRITE+NOTIFY characteristic. Android reads version on every BLE connect, shows toast if an update is available. OTA flow: app sends firmware chunks via BLE → ESP writes to ota_1 via esp_ota_* API → reboot. Relays drop OFF during OTA.
Tech Stack: ESP32-S3 Arduino (NimBLE, Preferences, mbedtls, esp_ota), Android Kotlin (standard BLE APIs, coroutines)
Spec: docs/superpowers/specs/2026-07-30-ble-ota-design.md
Task 1: Firmware — Add VERSION_UUID READ characteristic
Files:
-
Modify:
src/main.cpp -
Step 1: Define constant and add version characteristic in setup()
Add after #define UUID_CHAR_UUID:
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
Replace the NimBLECharacteristic *pUuidChar section in setup() with:
NimBLECharacteristic *pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ
);
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
NimBLECharacteristic *pVersionChar = pService->createCharacteristic(
VERSION_UUID,
NIMBLE_PROPERTY::READ
);
pVersionChar->setValue(FIRMWARE_VERSION);
- Step 2: Build firmware
cd /home/eddy/src/Project_SylDa && pio run
Expected: SUCCESS with no errors.
- Step 3: Flash and verify via USB serial
pio run -t upload
Open serial monitor (115200 baud) and send:
VERSION?
Expected: BUILD_NR:1
- Step 4: Verify via BLE from Android side
Open the Android app, connect to ESP. In BleRelayManager's onMtuChanged, add a read for the new VERSION_UUID characteristic (will be done in Task 5). For now, verify the characteristic is discoverable with nRF Connect or similar BLE scanner — should show a READ characteristic with value "1".
- Step 5: Commit
git add src/main.cpp
git commit -m "feat: add firmware version BLE characteristic (VERSION_UUID)"
Task 2: Firmware — Add OTA_UUID characteristic and OTA logic
Files:
-
Modify:
src/main.cpp -
Step 1: Add includes and constants
Add at top of file after existing includes:
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <nvs_flash.h>
Add after #define VERSION_UUID:
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define OTA_CHUNK_SIZE 500
#define OTA_CMD_START 0x10
#define OTA_CMD_END 0x11
#define OTA_STATUS_ACK 0x00
#define OTA_STATUS_NAK 0x01
#define OTA_STATUS_COMPLETE 0x10
#define OTA_STATUS_ERROR 0x11
Add global state variables after bool relay2State = false;:
bool otaInProgress = false;
esp_ota_handle_t otaHandle = 0;
const esp_partition_t *otaPartition = nullptr;
uint32_t otaExpectedSize = 0;
uint32_t otaWritten = 0;
uint8_t otaExpectedSha[32];
Add helper functions before setup():
static void ledOtaBusy() {
digitalWrite(LED_PIN, (millis() / 100) % 2 ? HIGH : LOW);
}
static void otaReset() {
otaInProgress = false;
otaHandle = 0;
otaPartition = nullptr;
otaExpectedSize = 0;
otaWritten = 0;
}
static void sendOtaNotify(NimBLECharacteristic *otaChar, uint8_t status, uint32_t seq) {
uint8_t buf[5];
buf[0] = status;
buf[1] = (seq >> 24) & 0xFF;
buf[2] = (seq >> 16) & 0xFF;
buf[3] = (seq >> 8) & 0xFF;
buf[4] = seq & 0xFF;
otaChar->setValue(buf, 5);
otaChar->notify();
}
static void abortOta(NimBLECharacteristic *otaChar, const char *reason) {
Serial.printf("OTA: abort — %s\n", reason);
if (otaHandle != 0) {
esp_ota_end(otaHandle);
}
otaReset();
sendOtaNotify(otaChar, OTA_STATUS_ERROR, 0);
}
- Step 2: Add OTA characteristic callback class
Add before class MyCharacteristicCallbacks:
class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
std::string value = pChar->getValue();
if (value.empty()) return;
const uint8_t *data = (const uint8_t *)value.data();
size_t len = value.length();
if (len == 5 && data[0] == OTA_CMD_START) {
if (otaInProgress) {
esp_ota_end(otaHandle);
otaReset();
}
otaExpectedSize = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) |
((uint32_t)data[3] << 8) | (uint32_t)data[4];
otaPartition = esp_ota_get_next_update_partition(nullptr);
if (otaPartition == nullptr) {
Serial.println("OTA: no update partition found");
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
esp_err_t err = esp_ota_begin(otaPartition, otaExpectedSize, &otaHandle);
if (err != ESP_OK) {
Serial.printf("OTA: begin failed (%d)\n", err);
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
otaInProgress = true;
otaWritten = 0;
digitalWrite(RELAY1_PIN, LOW);
digitalWrite(RELAY2_PIN, LOW);
relay1State = false;
relay2State = false;
bleRelay1 = false;
bleRelay2 = false;
Serial.printf("OTA: started, expecting %lu bytes\n", otaExpectedSize);
return;
}
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;
}
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: complete, rebooting...");
delay(500);
esp_restart();
return;
}
if (!otaInProgress) return;
if (data[0] < 0x10) {
uint32_t seq = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) |
((uint32_t)data[2] << 8) | (uint32_t)data[3];
size_t dataLen = len - 4;
esp_err_t err = esp_ota_write(otaHandle, data + 4, dataLen);
if (err != ESP_OK) {
Serial.printf("OTA: write err at seq %lu (%d)\n", seq, err);
sendOtaNotify(pChar, OTA_STATUS_NAK, seq);
abortOta(pChar, "write error");
return;
}
otaWritten += dataLen;
sendOtaNotify(pChar, OTA_STATUS_ACK, seq);
}
}
};
- Step 3: Add OTA characteristic to BLE service in setup()
Add after the pVersionChar block in setup():
NimBLECharacteristic *pOtaChar = pService->createCharacteristic(
OTA_UUID,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::NOTIFY
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
- Step 4: Update loop() to handle OTA mode
Modify the relay state logic in loop(). Replace:
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
relay2State = t2Active || (deviceConnected && bleRelay2);
With:
if (!otaInProgress) {
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
relay2State = t2Active || (deviceConnected && bleRelay2);
} else {
relay1State = false;
relay2State = false;
ledOtaBusy();
}
Also gate the serial command processing, BLE relay commands, and heartbeat logging with !otaInProgress. In the onWrite of MyCharacteristicCallbacks, add at the top:
if (otaInProgress) return;
In the serial handler, wrap the command processing in:
if (!otaInProgress) {
// existing serial command handling
}
And wrap the heartbeat log in:
if (!otaInProgress && millis() - heartbeat > 5000) {
- Step 5: Build firmware
cd /home/eddy/src/Project_SylDa && pio run
Expected: SUCCESS. Firmware should still be ~530-600KB.
- Step 6: Commit
git add src/main.cpp
git commit -m "feat: add BLE OTA characteristic with esp_ota integration"
Task 3: Firmware — Bump version to "2" and rebuild asset firmware
Files:
-
Modify:
src/main.cpp -
Overwrite:
android/app/src/main/assets/firmware/firmware.bin -
Step 1: Bump version
Change:
#define FIRMWARE_VERSION "1"
To:
#define FIRMWARE_VERSION "2"
- Step 2: Build and copy firmware to assets
cd /home/eddy/src/Project_SylDa && pio run && cp .pio/build/m5stack-atoms3r/firmware.bin android/app/src/main/assets/firmware/firmware.bin
- Step 3: Flash to ESP and verify version
pio run -t upload
Serial: send VERSION? → Expected: BUILD_NR:2
- Step 4: Commit
git add src/main.cpp android/app/src/main/assets/firmware/firmware.bin
git commit -m "feat: bump firmware to v2, update bundled firmware asset"
Task 4: Android — Update BleStatus and BleCallbacks
Files:
- Modify:
android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt - Modify:
android/app/src/main/java/nl/eddydevink/sylda/BleCallbacks.kt(extract from BleStatus.kt — it's currently in the same file)
Wait, BleCallbacks is already in BleStatus.kt. Let me just modify BleStatus.kt.
- Step 1: Add firmware version check callback to BleCallbacks
Modify BleCallbacks interface in BleStatus.kt. Add the method:
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
fun onFirmwareVersion(espVersion: String, appVersion: String)
}
- Step 2: Commit
git add android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt
git commit -m "feat: add onFirmwareVersion callback to BleCallbacks"
Task 5: Android — Update BleRelayManager for version + OTA characteristics
Files:
-
Modify:
android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt -
Step 1: Add companion instance for cross-activity access
Add before companion object:
companion object {
var instance: BleRelayManager? = null
And at the end of the constructor body (before closing brace of companion object), add:
Wait — the instance assignment must happen in the class body, not in companion object. Add after the init block doesn't exist, so use a field initializer approach. Actually, just add at the end of the primary constructor body. Since Kotlin doesn't have an init block here, add it inline with the property declarations:
After private var triedAddresses = mutableSetOf<String>(), add:
init {
instance = this
}
Full updated companion object + init:
companion object {
var instance: BleRelayManager? = null
private const val TAG = "BleRelayManager"
// ... keep existing companion members ...
}
// ... existing fields ...
init {
instance = this
}
- Step 2: Add UUID constants and MTU
Add after existing UUID constants:
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db")
private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
Change BLE_MTU from 256 to 512:
private const val BLE_MTU = 512
- Step 3: Add OTA characteristic field and version fields
Add after private var relayCharacteristic:
private var otaCharacteristic: BluetoothGattCharacteristic? = null
private var espVersion: String? = null
private var appVersion: String = ""
Add a method to set the bundled app version:
fun setAppVersion(version: String) {
appVersion = version
}
- Step 4: Read version characteristic on service discovery
In onMtuChanged, after reading the UUID characteristic, also read the version characteristic. The current code reads uuidChar only. Modify onMtuChanged:
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
} else {
Log.e(TAG, "Service not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
}
- Step 5: Handle version characteristic read in onCharacteristicRead
Add version handling to onCharacteristicRead. After the UUID check block, add:
if (characteristic.uuid == VERSION_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val version = String(value, Charsets.UTF_8).trim()
espVersion = version
Log.d(TAG, "ESP firmware version: $version, app bundled: $appVersion")
handler.post { callbacks.onFirmwareVersion(version, appVersion) }
}
- Step 6: Add OTA write method
Add a method to write OTA data:
fun sendOtaData(data: ByteArray) {
val char = otaCharacteristic ?: return
val gatt = bluetoothGatt ?: return
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
}
- Step 7: Add method to enable OTA notifications
@SuppressLint("MissingPermission")
fun enableOtaNotifications(): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
return gatt.setCharacteristicNotification(char, true)
}
- Step 8: Commit
git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt
git commit -m "feat: add version read, OTA characteristic, BLE MTU 512 in BleRelayManager"
Task 6: Android — Update MainActivity for version check toast
Files:
-
Modify:
android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt -
Step 1: Add version constant and implement callback
Add companion object constant:
private const val BUNDLED_FIRMWARE_VERSION = "2"
In onCreate, after initializing manager:
manager.setAppVersion(BUNDLED_FIRMWARE_VERSION)
Add onFirmwareVersion implementation to the MainActivity class:
override fun onFirmwareVersion(espVersion: String, appVersion: String) {
if (espVersion != appVersion) {
runOnUiThread {
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_LONG).show()
}
}
}
- Step 2: Commit
git add android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt
git commit -m "feat: show toast when firmware update available via BLE"
Task 7: Android — Create OtaManager.kt
Files:
-
Create:
android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt -
Step 1: Create OtaManager class
// 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.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); kotlinx.coroutines.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++
kotlinx.coroutines.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
}
}
- Step 2: Add import to OtaManager for delay
The delay and ensureActive calls need kotlinx.coroutines.delay and kotlinx.coroutines.ensureActive. These are already available through the lifecycle-runtime-ktx dependency.
- Step 3: Commit
git add android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "feat: add OtaManager for BLE firmware update"
Task 8: Android — Add OTA button and flow to SettingsActivity
Files:
-
Modify:
android/app/src/main/res/layout/activity_settings.xml -
Modify:
android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt -
Step 1 (layout): Add OTA button to activity_settings.xml
Add before the first <View divider (i.e., after the flashFirmwareButton Button):
<Button
android:id="@+id/otaUpdateButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_ota_update"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/otaStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:gravity="center"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:visibility="gone" />
- Step 2 (code): Add OTA button handler and logic to SettingsActivity
Add fields at top of class:
private var otaManager: OtaManager? = null
private var otaJob: Job? = null
Add OTA button setup in onCreate (after the flash button setup):
val otaButton = findViewById<Button>(R.id.otaUpdateButton)
otaButton.setOnClickListener {
startOtaUpdate()
}
Add the OTA methods:
private fun startOtaUpdate() {
val manager = BleRelayManager.instance
if (manager == null) {
Toast.makeText(this, "Niet verbonden met SylDa_Server", Toast.LENGTH_SHORT).show()
return
}
otaManager = OtaManager(this, manager)
if (otaManager?.loadFirmware() != true) {
Toast.makeText(this, getString(R.string.ota_load_failed), Toast.LENGTH_SHORT).show()
return
}
showOtaProgressDialog()
otaJob = lifecycleScope.launch {
val result = otaManager!!.performOta(object : OtaManager.OtaCallback {
override fun onProgress(percent: Int, message: String) {
lifecycleScope.launch(Dispatchers.Main) {
val d = progressDialog ?: return@launch
d.findViewById<ProgressBar>(R.id.progressBar)?.progress = percent
d.findViewById<TextView>(R.id.message)?.text = message
}
}
override fun onComplete() {
lifecycleScope.launch(Dispatchers.Main) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_complete), Toast.LENGTH_LONG).show()
}
}
override fun onError(message: String) {
lifecycleScope.launch(Dispatchers.Main) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_failed, message), Toast.LENGTH_LONG).show()
}
}
})
}
}
private fun showOtaProgressDialog() {
showProgressDialog(
getString(R.string.title_ota_update),
getString(R.string.msg_ota_starting),
STYLE_HORIZONTAL
)
}
Update onDestroy to cancel the OTA job:
override fun onDestroy() {
super.onDestroy()
currentAction = UsbAction.NONE
flasherJob?.cancel()
flasherJob = null
otaJob?.cancel()
otaJob = null
try { unregisterReceiver(usbAttachedReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbPermissionReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbDetachedReceiver) } catch (_: Exception) {}
}
- Step 3: Commit
git add android/app/src/main/res/layout/activity_settings.xml android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt
git commit -m "feat: add BLE OTA update button to SettingsActivity"
Task 9: Android — Add OTA strings
Files:
-
Modify:
android/app/src/main/res/values/strings.xml -
Step 1: Add OTA-related strings
Add before </resources>:
<!-- OTA Updates -->
<string name="button_ota_update">Firmware bijwerken via Bluetooth</string>
<string name="title_ota_update">OTA Firmware Update</string>
<string name="msg_ota_starting">OTA update starten...</string>
<string name="ota_update_available">Nieuwe firmware beschikbaar — open Instellingen om bij te werken</string>
<string name="ota_complete">Update voltooid! ESP herstart...</string>
<string name="ota_failed">OTA update mislukt: %s</string>
<string name="ota_load_failed">Kan firmware bestand niet laden</string>
- Step 2: Commit
git add android/app/src/main/res/values/strings.xml
git commit -m "feat: add OTA UI strings"
Task 10: Build APK and test full flow
Files:
-
All modified files from Tasks 1-9
-
Step 1: Build APK
cd /home/eddy/src/Project_SylDa/android && ./gradlew assembleDebug
Expected: BUILD SUCCESSFUL.
- Step 2: Install APK on test phone
Use the adb-wireless skill to connect to the phone, then:
adb -s 192.168.1.43:5555 install -r app/build/outputs/apk/debug/app-debug.apk
- Step 3: Test flow
- Open the app — should connect to SylDa_Server via BLE
- If ESP runs firmware v2 and app has v2: no toast (versions match)
- Flash ESP with v1 firmware (
git checkout HEAD~2 src/main.cpp && pio run -t upload), then test: should see toast "Nieuwe firmware beschikbaar" - Go to Settings → tap "Firmware bijwerken via Bluetooth" → should see progress bar
- After completion: ESP should reboot and run v2 firmware
- App reconnects, no toast (versions now match)
- Step 4: Verify rollback (optional test)
Build a bad firmware (e.g., intentionally corrupt) and push it. ESP should fail esp_ota_end() and stay on current firmware.
- Step 5: Commit (re-flash v2)
git stash # restore v2 main.cpp
pio run -t upload
Final commit if any changes were made during testing:
git add -A
git commit -m "fix: OTA test adjustments"