From 5524f173acee88e18e7175269eb56ce3e7255969 Mon Sep 17 00:00:00 2001 From: eddy Date: Thu, 30 Jul 2026 06:30:56 +0200 Subject: [PATCH 01/25] docs: BLE OTA firmware update design spec --- .../specs/2026-07-30-ble-ota-design.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-ble-ota-design.md diff --git a/docs/superpowers/specs/2026-07-30-ble-ota-design.md b/docs/superpowers/specs/2026-07-30-ble-ota-design.md new file mode 100644 index 0000000..96df1ae --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-ble-ota-design.md @@ -0,0 +1,116 @@ +# BLE OTA Firmware Updates — Design + +## Doel + +Firmware-updates via Bluetooth LE vanaf de Android app, zonder USB-kabel. Doelgroep: lichamelijk gehandicapten — zelfstandigheid zonder fysieke kabels. + +## Huidige situatie + +- Firmware: ~523KB (534.793 bytes, 16% van 3.2MB beschikbaar) +- BLE: NimBLE-Arduino, service "SylDa_Server" actief +- Partitietabel: al OTA-capable — `ota_0` (0x10000, 3264K), `ota_1` (0x340000, 3264K), `otadata` (0xe000, 8K) +- Android app: flasht nu via USB CDC (EspFlasher) + +## Architectuur + +### ESP32-S3 kant (`src/main.cpp`) + +- Nieuwe BLE characteristic `OTA_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26da"` — WRITE + NOTIFY +- Bestaande relay service blijft volledig intact, aparte characteristic in dezelfde service +- OTA-modus flag: tijdens OTA worden relay-commando's genegeerd, relais vallen UIT +- Gebruikt ESP-IDF `esp_ota_*` API: + - `esp_ota_get_next_update_partition()` — vindt target partitie + - `esp_ota_begin()` — initialiseert schrijfactie + - `esp_ota_write()` — schrijft chunk naar flash + - `esp_ota_end()` — valideert image, markeert als bootable + - `esp_ota_set_boot_partition()` — zet target als actieve bootpartitie + - `esp_restart()` — herstart naar nieuwe firmware + +### Android kant + +- Nieuwe `OtaManager.kt` — leest firmware, hasht, chunkt, verstuurt +- OTA-knop in `SettingsActivity.kt` met progress UI +- Firmware bron: `assets/firmware.bin` (of bestandskiezer voor handmatige update) +- SHA-256 hash berekening voor eindverificatie + +## BLE OTA Protocol + +``` +OTA Characteristic UUID: beb5483e-36e1-4688-b7f5-ea07361f26da +Properties: WRITE, NOTIFY +MTU: 512 bytes (negotiated) +Chunk nuttige data: 488 bytes (512 - 24 header overhead) +``` + +### Write commando's (App → ESP) + +| Byte(s) | Commando | +|---|---| +| `0x10` + 4 bytes size (LE) | OTA_START — firmware grootte in bytes | +| 4 bytes seq (LE) + data[...] | OTA_DATA — chunk met sequentienummer | +| 32 bytes SHA-256 | OTA_END — hash van volledige firmware | + +### Notificatie commando's (ESP → App) + +| Byte(s) | Betekenis | +|---|---| +| `0x00` + 4 bytes seq (LE) | ACK — chunk ontvangen en geschreven | +| `0x01` + 4 bytes seq (LE) | NAK — chunk opnieuw versturen | +| `0x10` | OTA_COMPLETE — succes, reboot volgt | +| `0x11` + 1 byte error | OTA_ERROR | + +### Flow + +1. App stuurt `OTA_START` met firmware-grootte +2. ESP roept `esp_ota_begin()` aan, zet OTA-modus, relais UIT +3. App stuurt chunks sequentieel (seq 0, 1, 2, ...) +4. ESP schrijft elke chunk met `esp_ota_write()`, stuurt ACK terug +5. Bij timeout of corruptie: ESP stuurt NAK met seq-nummer, app herhaalt chunk +6. App stuurt `OTA_END` met SHA-256 +7. ESP: `esp_ota_end()`, `esp_ota_set_boot_partition()`, stuurt `OTA_COMPLETE` +8. ESP: `esp_restart()` + +## LED-signalering + +| Patroon | Betekenis | +|---|---| +| Snel knipperen (100ms) | OTA bezig | +| Continu aan | OTA voltooid, reboot imminent | +| Langzaam knipperen (500ms) | OTA fout | + +## Beveiliging + +- Firmware wordt NIET per chunk met HMAC geauthenticeerd (snelheid) +- SHA-256 hash van volledige firmware meegezonden in OTA_END +- `esp_ota_end()` verifieert image header checksum automatisch +- Alleen een BLE-verbonden device kan OTA starten (BLE link-level encryptie via pairing) +- Bestaande HMAC-key voor relay-commando's blijft ongewijzigd + +## Foutafhandeling + +| Scenario | Afhandeling | +|---|---| +| Verbinding verbroken tijdens OTA | Huidige firmware blijft actief, `ota_1` ongeldig | +| Corrupte chunk | ESP stuurt NAK, app herhaalt chunk | +| SHA-256 mismatch in OTA_END | `esp_ota_end()` faalt, huidige firmware blijft | +| ESP crash tijdens OTA | Bootloader boot `ota_0`, `otadata` wijst er nog naar | +| Nieuwe firmware boot niet (8x bootloop) | ESP-IDF rollback: schakelt terug naar `ota_0` | + +## Edge cases + +- **OTA opnieuw starten:** `ota_1` wordt overschreven, geen clean-up nodig +- **Versie-check:** App kan firmwareversie opvragen voordat OTA start +- **Tijdens actieve relais:** relais vallen UIT bij start OTA (rust-toestand) + +## Transfertijd schatting + +523KB bij ~30 KB/s effectieve BLE throughput ≈ 17 seconden. Bij slechtere verbinding ~45-60 seconden. Acceptabel. + +## Bestanden + +| Bestand | Wijziging | +|---|---| +| `src/main.cpp` | OTA characteristic, `esp_ota_*` logica, OTA-modus flag, LED-signalering | +| `platformio.ini` | Geen wijziging (partitietabel al OTA-ready) | +| `android/.../OtaManager.kt` | Nieuw: firmware lezen, hashen, chunken, BLE verzenden | +| `android/.../SettingsActivity.kt` | OTA-knop + progress UI | From 6df65d9adf291a085e018c1f283382cc2c6c6b71 Mon Sep 17 00:00:00 2001 From: eddy Date: Thu, 30 Jul 2026 06:56:04 +0200 Subject: [PATCH 02/25] docs: add version check on BLE connect to OTA spec --- .../specs/2026-07-30-ble-ota-design.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-30-ble-ota-design.md b/docs/superpowers/specs/2026-07-30-ble-ota-design.md index 96df1ae..93d33be 100644 --- a/docs/superpowers/specs/2026-07-30-ble-ota-design.md +++ b/docs/superpowers/specs/2026-07-30-ble-ota-design.md @@ -15,6 +15,7 @@ Firmware-updates via Bluetooth LE vanaf de Android app, zonder USB-kabel. Doelgr ### ESP32-S3 kant (`src/main.cpp`) +- Nieuwe BLE characteristic `VERSION_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26db"` — READ, exposeert firmwareversie als string - Nieuwe BLE characteristic `OTA_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26da"` — WRITE + NOTIFY - Bestaande relay service blijft volledig intact, aparte characteristic in dezelfde service - OTA-modus flag: tijdens OTA worden relay-commando's genegeerd, relais vallen UIT @@ -30,8 +31,19 @@ Firmware-updates via Bluetooth LE vanaf de Android app, zonder USB-kabel. Doelgr - Nieuwe `OtaManager.kt` — leest firmware, hasht, chunkt, verstuurt - OTA-knop in `SettingsActivity.kt` met progress UI +- Bij BLE connect: leest firmwareversie van ESP via `VERSION_UUID` characteristic, vergelijkt met versie in app. Toont toast bij beschikbare update. - Firmware bron: `assets/firmware.bin` (of bestandskiezer voor handmatige update) - SHA-256 hash berekening voor eindverificatie +- Bundled firmwareversie (uit `assets/`) vergeleken met ESP-versie bij connect + +## Versie-check bij BLE connect + +Bij elke BLE-verbinding leest de Android app de `VERSION_UUID` READ characteristic van de ESP. De ESP retourneert de stringwaarde van `FIRMWARE_VERSION`. De app vergelijkt met de versie van de gebundelde `firmware.bin` in assets. Als de ESP een lagere versie heeft toont de app een toast: "Firmware-update beschikbaar" — de gebruiker kan dan in SettingsActivity de OTA starten. + +| VERSION_UUID | "beb5483e-36e1-4688-b7f5-ea07361f26db" | +|---|---| +| Properties | READ | +| Waarde | String, bijv. `"1"` | ## BLE OTA Protocol @@ -99,7 +111,7 @@ Chunk nuttige data: 488 bytes (512 - 24 header overhead) ## Edge cases - **OTA opnieuw starten:** `ota_1` wordt overschreven, geen clean-up nodig -- **Versie-check:** App kan firmwareversie opvragen voordat OTA start +- **Versie-check:** Bij BLE connect leest de app de firmwareversie van de ESP (`VERSION_UUID` characteristic). Bij mismatch met gebundelde firmware toont app een toast "Firmware-update beschikbaar" en activeert de OTA-knop. - **Tijdens actieve relais:** relais vallen UIT bij start OTA (rust-toestand) ## Transfertijd schatting @@ -110,7 +122,7 @@ Chunk nuttige data: 488 bytes (512 - 24 header overhead) | Bestand | Wijziging | |---|---| -| `src/main.cpp` | OTA characteristic, `esp_ota_*` logica, OTA-modus flag, LED-signalering | +| `src/main.cpp` | VERSION characteristic, OTA characteristic, `esp_ota_*` logica, OTA-modus flag, LED-signalering | | `platformio.ini` | Geen wijziging (partitietabel al OTA-ready) | | `android/.../OtaManager.kt` | Nieuw: firmware lezen, hashen, chunken, BLE verzenden | | `android/.../SettingsActivity.kt` | OTA-knop + progress UI | From 5ed27a9b42898b295da9159dce9174a339ee819f Mon Sep 17 00:00:00 2001 From: eddy Date: Thu, 30 Jul 2026 07:04:28 +0200 Subject: [PATCH 03/25] docs: BLE OTA firmware update implementation plan --- docs/superpowers/plans/2026-07-30-ble-ota.md | 900 +++++++++++++++++++ 1 file changed, 900 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-ble-ota.md diff --git a/docs/superpowers/plans/2026-07-30-ble-ota.md b/docs/superpowers/plans/2026-07-30-ble-ota.md new file mode 100644 index 0000000..4de84e0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-ble-ota.md @@ -0,0 +1,900 @@ +# 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