900 lines
26 KiB
Markdown
900 lines
26 KiB
Markdown
# 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 <esp_ota_ops.h>
|
|
#include <esp_partition.h>
|
|
#include <nvs_flash.h>
|
|
```
|
|
|
|
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<String>()`, 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 `<View` divider (i.e., after the `flashFirmwareButton` Button):
|
|
|
|
```xml
|
|
<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:
|
|
|
|
```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<Button>(R.id.otaUpdateButton)
|
|
otaButton.setOnClickListener {
|
|
startOtaUpdate()
|
|
}
|
|
```
|
|
|
|
Add the OTA methods:
|
|
|
|
```kotlin
|
|
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:
|
|
|
|
```kotlin
|
|
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**
|
|
|
|
```bash
|
|
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>`:
|
|
|
|
```xml
|
|
<!-- 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
adb -s 192.168.1.43:5555 install -r app/build/outputs/apk/debug/app-debug.apk
|
|
```
|
|
|
|
- [ ] **Step 3: Test flow**
|
|
|
|
1. Open the app — should connect to SylDa_Server via BLE
|
|
2. If ESP runs firmware v2 and app has v2: no toast (versions match)
|
|
3. Flash ESP with v1 firmware (`git checkout HEAD~2 src/main.cpp && pio run -t upload`), then test: should see toast "Nieuwe firmware beschikbaar"
|
|
4. Go to Settings → tap "Firmware bijwerken via Bluetooth" → should see progress bar
|
|
5. After completion: ESP should reboot and run v2 firmware
|
|
6. 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)**
|
|
|
|
```bash
|
|
git stash # restore v2 main.cpp
|
|
pio run -t upload
|
|
```
|
|
|
|
Final commit if any changes were made during testing:
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: OTA test adjustments"
|
|
```
|