# 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`: ```cpp #define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db" ``` Replace the `NimBLECharacteristic *pUuidChar` section in setup() with: ```cpp 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** ```bash cd /home/eddy/src/Project_SylDa && pio run ``` Expected: SUCCESS with no errors. - [ ] **Step 3: Flash and verify via USB serial** ```bash 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** ```bash 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: ```cpp #include #include #include ``` Add after `#define VERSION_UUID`: ```cpp #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;`: ```cpp 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()`: ```cpp 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`: ```cpp 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(): ```cpp 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: ```cpp relay1State = t1Active || btnActive || (deviceConnected && bleRelay1); relay2State = t2Active || (deviceConnected && bleRelay2); ``` With: ```cpp 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: ```cpp if (otaInProgress) return; ``` In the serial handler, wrap the command processing in: ```cpp if (!otaInProgress) { // existing serial command handling } ``` And wrap the heartbeat log in: ```cpp if (!otaInProgress && millis() - heartbeat > 5000) { ``` - [ ] **Step 5: Build firmware** ```bash cd /home/eddy/src/Project_SylDa && pio run ``` Expected: SUCCESS. Firmware should still be ~530-600KB. - [ ] **Step 6: Commit** ```bash 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: ```cpp #define FIRMWARE_VERSION "1" ``` To: ```cpp #define FIRMWARE_VERSION "2" ``` - [ ] **Step 2: Build and copy firmware to assets** ```bash 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** ```bash pio run -t upload ``` Serial: send `VERSION?` → Expected: `BUILD_NR:2` - [ ] **Step 4: Commit** ```bash 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: ```kotlin interface BleCallbacks { fun onStatusChanged(status: BleStatus) fun onFirmwareVersion(espVersion: String, appVersion: String) } ``` - [ ] **Step 2: Commit** ```bash 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`: ```kotlin 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()`, add: ```kotlin init { instance = this } ``` **Full updated companion object + init:** ```kotlin 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: ```kotlin 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: ```kotlin private const val BLE_MTU = 512 ``` - [ ] **Step 3: Add OTA characteristic field and version fields** Add after `private var relayCharacteristic`: ```kotlin private var otaCharacteristic: BluetoothGattCharacteristic? = null private var espVersion: String? = null private var appVersion: String = "" ``` Add a method to set the bundled app version: ```kotlin 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`: ```kotlin 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: ```kotlin 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: ```kotlin 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** ```kotlin @SuppressLint("MissingPermission") fun enableOtaNotifications(): Boolean { val char = otaCharacteristic ?: return false val gatt = bluetoothGatt ?: return false return gatt.setCharacteristicNotification(char, true) } ``` - [ ] **Step 8: Commit** ```bash 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: ```kotlin private const val BUNDLED_FIRMWARE_VERSION = "2" ``` In `onCreate`, after initializing `manager`: ```kotlin manager.setAppVersion(BUNDLED_FIRMWARE_VERSION) ``` Add `onFirmwareVersion` implementation to the `MainActivity` class: ```kotlin 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** ```bash 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** ```kotlin // 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** ```bash 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 ` ``` - [ ] **Step 2 (code): Add OTA button handler and logic to SettingsActivity** Add fields at top of class: ```kotlin private var otaManager: OtaManager? = null private var otaJob: Job? = null ``` Add OTA button setup in `onCreate` (after the flash button setup): ```kotlin val otaButton = findViewById