Compare commits

...
Author SHA1 Message Date
eddy
97a1eff6fa chore: update bundled firmware.bin with SHA256 verification 2026-07-30 19:17:20 +02:00
eddy
69c4ee040d chore: remove unused otaStatusText widget 2026-07-30 19:16:56 +02:00
eddy
44297d30ad fix: check writeCharacteristic return value in sendOtaData 2026-07-30 19:16:28 +02:00
eddy
fcbf1e9b5f feat: ESP MTU 512 + dynamic Android chunk size based on negotiated MTU 2026-07-30 19:15:55 +02:00
eddy
475c029512 feat: OTA notification handling with ACK/NAK retry logic 2026-07-30 19:14:23 +02:00
eddy
ea8bab4571 feat: incremental SHA256 verification before esp_ota_set_boot_partition 2026-07-30 19:13:27 +02:00
eddy
8a54367e30 docs: OTA improvements implementation plan 2026-07-30 19:08:18 +02:00
eddy
b1591b7bdb docs: OTA improvements design spec 2026-07-30 19:04:34 +02:00
eddy
0513e7777d fix: USB/OTA update flow - version check, chunk size, UX improvements
- MainActivity: suppress toast when activity not resumed, auto-navigate to
  Settings on new firmware
- SettingsActivity: unified BLE/USB radio buttons + single update button,
  version check before OTA, show dialog before loadFirmware, spinner timeout
- BleRelayManager: expose getEspVersion()
- OtaManager: reduce chunk size 500→248 to fit BLE MTU 255
- ESP: WRITE→WRITE_NR on OTA characteristic (was silently dropping data)
- Layout/strings: remove intro text, add method radio buttons
- Bump all firmware versions to v5
2026-07-30 18:59:08 +02:00
eddy
e05d57a32c fix: detecteer ontbrekende OTA characteristic en toon USB-flash melding 2026-07-30 17:37:05 +02:00
eddy
7e45cb4c56 feat: toon 'Firmware is up-to-date' toast bij gelijke versies 2026-07-30 17:29:09 +02:00
eddy
57ef68fcab fix: toast tekst ingekort, settings balk volledig klikbaar, version-check fallback voor oude ESP firmware 2026-07-30 17:25:57 +02:00
eddy
1499d840ce fix: handle missing VERSION characteristic gracefully on old ESP firmware 2026-07-30 17:20:38 +02:00
eddy
7607361019 feat: add OTA UI strings 2026-07-30 17:14:29 +02:00
eddy
1988ae3898 feat: add BLE OTA update button to SettingsActivity 2026-07-30 17:14:28 +02:00
eddy
675504cbf5 feat: add OtaManager for BLE firmware update 2026-07-30 17:14:27 +02:00
eddy
2ab1c83655 feat: show toast when firmware update available via BLE 2026-07-30 17:14:26 +02:00
eddy
5235057b83 feat: add version read, OTA characteristic, BLE MTU 512 in BleRelayManager 2026-07-30 17:14:25 +02:00
eddy
6042eceeea feat: add onFirmwareVersion callback to BleCallbacks 2026-07-30 17:14:24 +02:00
eddy
3e880c9635 feat: bump firmware to v2, update bundled firmware asset 2026-07-30 17:09:09 +02:00
eddy
a1b0daee37 feat: add BLE OTA characteristic with esp_ota integration 2026-07-30 17:08:48 +02:00
eddy
d34dfec1ce feat: add firmware version BLE characteristic (VERSION_UUID) 2026-07-30 17:07:26 +02:00
eddy
5ed27a9b42 docs: BLE OTA firmware update implementation plan 2026-07-30 07:04:28 +02:00
eddy
6df65d9adf docs: add version check on BLE connect to OTA spec 2026-07-30 06:56:04 +02:00
eddy
5524f173ac docs: BLE OTA firmware update design spec 2026-07-30 06:30:56 +02:00
14 changed files with 2236 additions and 32 deletions

View file

@ -23,23 +23,27 @@ import java.nio.ByteOrder
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.text.Charsets
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
companion object {
var instance: BleRelayManager? = null
private const val TAG = "BleRelayManager"
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db")
private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
private const val MAX_SCAN_RETRIES = 3
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
private const val BLE_MTU = 256
private const val BLE_MTU = 512
}
private var bluetoothGatt: BluetoothGatt? = null
@ -52,6 +56,24 @@ class BleRelayManager(
private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private var otaCharacteristic: BluetoothGattCharacteristic? = null
private var espVersion: String? = null
private var appVersion: String = ""
private var negotiatedMtu: Int = 23
interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int)
}
private var otaNotifyCallback: OtaNotifyCallback? = null
fun setOtaNotifyCallback(callback: OtaNotifyCallback?) {
otaNotifyCallback = callback
}
init {
instance = this
}
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
@ -170,6 +192,26 @@ class BleRelayManager(
stopScan()
bluetoothGatt?.disconnect()
}
@SuppressLint("MissingPermission")
fun sendOtaData(data: ByteArray): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return gatt.writeCharacteristic(char)
}
@SuppressLint("MissingPermission")
fun enableOtaNotifications(): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
return gatt.setCharacteristicNotification(char, true)
}
fun hasOtaCharacteristic(): Boolean {
return otaCharacteristic != null
}
fun updateKeys() {
loadState()
}
@ -182,6 +224,14 @@ class BleRelayManager(
return bluetoothGatt != null
}
fun setAppVersion(version: String) {
appVersion = version
}
fun getEspVersion(): String? = espVersion
fun getNegotiatedMtu(): Int = negotiatedMtu
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
@ -227,10 +277,13 @@ class BleRelayManager(
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
negotiatedMtu = mtu
val service = gatt.getService(SERVICE_UUID)
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)
@ -249,14 +302,38 @@ class BleRelayManager(
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val storedUuid = deviceUuid
if (storedUuid != null && value.contentEquals(storedUuid)) {
Log.d(TAG, "UUID verified, setting up relay")
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
Log.d(TAG, "UUID verified, reading version...")
val service = gatt.getService(SERVICE_UUID)
val versionChar = service?.getCharacteristic(VERSION_UUID)
if (versionChar != null) {
val ok = gatt.readCharacteristic(versionChar)
if (!ok) {
Log.w(TAG, "readCharacteristic(VERSION) returned false, treating as no version")
handler.post { callbacks.onFirmwareVersion("", appVersion) }
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
}
} else {
Log.d(TAG, "VERSION characteristic not found on ESP (old firmware)")
handler.post { callbacks.onFirmwareVersion("", appVersion) }
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
}
} else {
Log.w(TAG, "UUID mismatch -- disconnecting")
triedAddresses.add(gatt.device.address)
handler.post { callbacks.onStatusChanged(BleStatus.WrongDevice) }
gatt.disconnect()
}
} else if (characteristic.uuid == VERSION_UUID) {
if (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) }
} else {
Log.w(TAG, "Version read failed (status=$status), treating as no version")
handler.post { callbacks.onFirmwareVersion("", appVersion) }
}
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
}
}
@ -267,6 +344,25 @@ class BleRelayManager(
handler.post { callbacks.onStatusChanged(BleStatus.CommError) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val status = value[0].toInt() and 0xFF
val seq = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF)
} else 0
Log.d(TAG, "OTA notify: status=$status seq=$seq")
handler.post { otaNotifyCallback?.onOtaNotify(status, seq) }
}
}
}
private fun loadState() {

View file

@ -18,4 +18,5 @@ enum class BleStatus {
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
fun onFirmwareVersion(espVersion: String, appVersion: String)
}

View file

@ -10,8 +10,8 @@ import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
@ -22,6 +22,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
private const val REQUEST_SETTINGS = 1
private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L
private const val BUNDLED_FIRMWARE_VERSION = "5"
}
private lateinit var manager: BleRelayManager
@ -90,13 +91,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
setContentView(R.layout.activity_main)
manager = BleRelayManager(this, this)
manager.setAppVersion(BUNDLED_FIRMWARE_VERSION)
statusText = findViewById(R.id.statusText)
relay1Button = findViewById(R.id.relay1Button)
relay2Button = findViewById(R.id.relay2Button)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
val settingsContainer = findViewById<android.view.View>(R.id.settingsContainer)
settingsButton.setOnClickListener {
settingsContainer.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
}
setupRelayButton()
@ -217,4 +219,16 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
handler.removeCallbacksAndMessages(null)
manager.destroy()
}
override fun onFirmwareVersion(espVersion: String, appVersion: String) {
runOnUiThread {
if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread
if (espVersion.isEmpty() || espVersion != appVersion) {
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_SHORT).show()
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
} else {
Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
}
}
}
}

View file

@ -0,0 +1,184 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
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_CMD_START: Byte = 0x10
private const val OTA_START_TIMEOUT_MS = 10000L
private const val OTA_CHUNK_TIMEOUT_MS = 2000L
private const val OTA_FINAL_TIMEOUT_MS = 10000L
private const val MAX_RETRIES = 3
private const val OTA_ESP_MAX_CHUNK = 500
}
interface OtaCallback {
fun onProgress(percent: Int, message: String)
fun onComplete()
fun onError(message: String)
}
private var firmwareData: ByteArray? = null
private var chunkDataSize: Int = 248
private fun calculateChunkSize(mtu: Int): Int {
return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK)
}
fun loadFirmware(): Boolean {
return try {
firmwareData = context.assets.open("firmware/firmware.bin").readBytes()
chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu())
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)
var chunkAckReceived = false
var chunkNakReceived = false
var finalStatusReceived = false
var finalStatus = -1
val notifyCallback = object : BleRelayManager.OtaNotifyCallback {
override fun onOtaNotify(status: Int, seq: Int) {
when (status) {
0x00 -> chunkAckReceived = true
0x01 -> chunkNakReceived = true
0x10 -> { finalStatusReceived = true; finalStatus = 0x10 }
0x11 -> { finalStatusReceived = true; finalStatus = 0x11 }
}
}
}
relayManager.setOtaNotifyCallback(notifyCallback)
try {
withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") }
val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN)
startPacket.put(OTA_CMD_START)
startPacket.putInt(fw.size)
relayManager.sendOtaData(startPacket.array())
try {
withTimeout(OTA_START_TIMEOUT_MS) {
while (true) { delay(100); ensureActive() }
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {}
val totalChunks = (fw.size + chunkDataSize - 1) / chunkDataSize
var seq = 0
var offset = 0
while (offset < fw.size) {
val remaining = fw.size - offset
val chunkSize = minOf(remaining, chunkDataSize)
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
packet.putInt(seq)
packet.put(fw, offset, chunkSize)
chunkAckReceived = false
chunkNakReceived = false
var retries = 0
while (retries < MAX_RETRIES) {
val writeOk = relayManager.sendOtaData(packet.array())
if (!writeOk) {
Log.w(TAG, "writeCharacteristic returned false for chunk $seq, retrying")
retries++
delay(100)
continue
}
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
try {
withTimeout(OTA_CHUNK_TIMEOUT_MS) {
while (!chunkAckReceived && !chunkNakReceived) {
delay(50)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for ACK/NAK on chunk $seq")
}
if (chunkAckReceived) break
retries++
Log.w(TAG, "Chunk $seq NAK/timeout, retry $retries/$MAX_RETRIES")
}
if (!chunkAckReceived) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na $MAX_RETRIES pogingen") }
return@withContext false
}
val pct = ((offset.toFloat() / fw.size.toFloat()) * 99).toInt().coerceAtMost(99)
withContext(Dispatchers.Main) {
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
}
offset += chunkSize
seq++
}
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
finalStatusReceived = false
relayManager.sendOtaData(sha256)
try {
withTimeout(OTA_FINAL_TIMEOUT_MS) {
while (!finalStatusReceived) {
delay(100)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for final OTA status")
}
relayManager.setOtaNotifyCallback(null)
if (finalStatus == 0x10) {
withContext(Dispatchers.Main) {
callback.onProgress(100, "Herstart...")
callback.onComplete()
}
true
} else {
withContext(Dispatchers.Main) {
callback.onError("OTA mislukt: ESP SHA256 verificatie fout")
}
false
}
} catch (e: Exception) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA fout: ${e.message}") }
false
}
}
}

View file

@ -64,7 +64,7 @@ class SettingsActivity : AppCompatActivity() {
private const val REBOOT_WAIT_MS = 5000L
private const val REBOOT_POLL_MS = 500L
private const val REBOOT_MAX_WAIT_MS = 15000L
private const val EXPECTED_FIRMWARE_VERSION = "1"
private const val EXPECTED_FIRMWARE_VERSION = "5"
}
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
@ -74,7 +74,11 @@ class SettingsActivity : AppCompatActivity() {
private var progressDialog: AlertDialog? = null
private var dialog: AlertDialog? = null
private var flasherJob: Job? = null
private var otaManager: OtaManager? = null
private var otaJob: Job? = null
private var lastTheme: String? = null
private val rebootTimeoutHandler = Handler(Looper.getMainLooper())
private var rebootTimeoutRunnable: Runnable? = null
private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@ -136,8 +140,12 @@ class SettingsActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = getString(R.string.title_settings)
findViewById<Button>(R.id.flashFirmwareButton).setOnClickListener {
startFlashing()
findViewById<Button>(R.id.updateModuleButton).setOnClickListener {
if (findViewById<RadioGroup>(R.id.flashMethodGroup).checkedRadioButtonId == R.id.methodUsb) {
startFlashing()
} else {
startOtaUpdate()
}
}
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
@ -202,11 +210,82 @@ class SettingsActivity : AppCompatActivity() {
}
}
private fun startOtaUpdate() {
val manager = BleRelayManager.instance
if (manager == null) {
Toast.makeText(this, "Niet verbonden met SylDa_Server", Toast.LENGTH_SHORT).show()
return
}
if (!manager.hasOtaCharacteristic()) {
Toast.makeText(this, getString(R.string.ota_needs_usb_first), Toast.LENGTH_LONG).show()
return
}
val espVersion = manager.getEspVersion()
if (espVersion != null && espVersion == EXPECTED_FIRMWARE_VERSION) {
Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
return
}
showOtaProgressDialog()
otaManager = OtaManager(this, manager)
if (otaManager?.loadFirmware() != true) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this, getString(R.string.ota_load_failed), Toast.LENGTH_SHORT).show()
return
}
manager.enableOtaNotifications()
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
val pb = d.findViewById<ProgressBar>(R.id.progressBar)
val msg = d.findViewById<TextView>(R.id.message)
pb?.progress = percent
msg?.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
)
}
override fun onDestroy() {
super.onDestroy()
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
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) {}
@ -331,9 +410,10 @@ class SettingsActivity : AppCompatActivity() {
lifecycleScope.launch {
val version = readEspVersion(usbManager, device)
if (version == EXPECTED_FIRMWARE_VERSION && !expectingReboot) {
Log.d(TAG, "Firmware version $version matches, skipping flash")
Log.d(TAG, "Firmware version $version matches, up to date")
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
currentAction = UsbAction.EXCHANGE_KEY
doKeyExchange(usbManager, device)
return@launch
@ -372,6 +452,8 @@ class SettingsActivity : AppCompatActivity() {
getString(R.string.title_firmware_flash),
getString(R.string.msg_env_waiting_reboot)
)
rebootTimeoutRunnable = Runnable { cleanupAction() }
rebootTimeoutHandler.postDelayed(rebootTimeoutRunnable!!, REBOOT_MAX_WAIT_MS)
} catch (e: kotlinx.coroutines.CancellationException) {
cleanupAction()
throw e
@ -388,6 +470,8 @@ class SettingsActivity : AppCompatActivity() {
// === USB dispatch ===
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
when (currentAction) {
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
UsbAction.FLASH -> doFlashing(usbManager, device)
@ -436,6 +520,8 @@ class SettingsActivity : AppCompatActivity() {
private fun cleanupAction() {
currentAction = UsbAction.NONE
expectingReboot = false
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
dialog?.dismiss()
dialog = null
progressDialog?.dismiss()

View file

@ -4,12 +4,16 @@
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/settingsContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<TextView
android:layout_width="wrap_content"

View file

@ -5,18 +5,36 @@
android:orientation="vertical"
android:padding="24dp">
<TextView
<RadioGroup
android:id="@+id/flashMethodGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/msg_flash_mode_help"
android:textSize="14sp"
android:layout_marginBottom="16dp" />
android:orientation="horizontal"
android:layout_marginBottom="8dp"
android:checkedButton="@id/methodBluetooth">
<RadioButton
android:id="@+id/methodBluetooth"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/method_bluetooth" />
<RadioButton
android:id="@+id/methodUsb"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/method_usb" />
</RadioGroup>
<Button
android:id="@+id/flashFirmwareButton"
android:id="@+id/updateModuleButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_firmware_flash" />
android:text="@string/button_update_module" />
<View
android:layout_width="match_parent"

View file

@ -27,9 +27,9 @@
<!-- SettingsActivity -->
<string name="title_settings">Instellingen</string>
<string name="msg_settings_intro">Sluit de ESP aan op USB en kies een actie.</string>
<string name="msg_flash_mode_help">Sluit de module aan op de USB-poort.</string>
<string name="button_firmware_flash">Firmware bijwerken</string>
<string name="method_bluetooth">Bluetooth</string>
<string name="method_usb">USB</string>
<string name="button_update_module">Module bijwerken</string>
<string name="usb_access_denied">USB-toegang geweigerd</string>
<string name="key_read_success">Beveiligingssleutel gelezen</string>
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</string>
@ -102,4 +102,14 @@
<string name="theme_light">Licht</string>
<string name="theme_dark">Donker</string>
<string name="theme_applied">Thema: %s</string>
<!-- OTA Updates -->
<string name="title_ota_update">Module bijwerken</string>
<string name="msg_ota_starting">OTA update starten...</string>
<string name="ota_update_available">Nieuwe firmware beschikbaar</string>
<string name="ota_up_to_date">Firmware is up‑to‑date</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>
<string name="ota_needs_usb_first">Module heeft nog geen OTA-ondersteuning — eerst flashen via USB</string>
</resources>

View file

@ -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 <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"
```

View file

@ -0,0 +1,500 @@
# OTA Improvements — Implementatieplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Zes code review aanbevelingen implementeren: SHA256 verificatie op ESP, OTA notification handling op Android, dynamische chunk size, writeCharacteristic return check, otaStatusText verwijderen.
**Architecture:** ESP stuurt al ACK/NAK/COMPLETE/ERROR notificaties — Android moet ze alleen nog verwerken. Chunk size wordt dynamisch afgeleid van onderhandelde MTU (512 via `setMTU`). SHA256 verificatie voorkomt dat corrupte firmware de boot partitie overschrijft.
**Tech Stack:** C++ (ESP32, NimBLE, esp_ota_ops, mbedtls), Kotlin (Android, BLE GATT, Coroutines)
**Verify:** Na elke task: compilatie (`pio run` voor ESP, `./gradlew assembleDebug` voor Android). Geen automated tests — handmatig flashen naar ESP en OTA testen na alle tasks.
---
### Task 1: ESP SHA256 verificatie
**Files:**
- Modify: `src/main.cpp:192-213`
De ESP ontvangt al de SHA256 hash als laatste chunk (32 bytes, opgeslagen in `otaExpectedSha`) en roept `esp_ota_end()` aan. Wat ontbreekt: `esp_ota_get_app_elf_sha256()` + `memcmp()` vóór `esp_ota_set_boot_partition()`.
- [ ] **Step 1: Vervang CMD_END handler met SHA256 verificatie**
In `src/main.cpp`, vervang de bestaande 32-byte handler (regels 192-213):
```cpp
if (len == 32 && otaInProgress) {
memcpy(otaExpectedSha, data, 32);
esp_err_t err = esp_ota_end(otaHandle);
if (err != ESP_OK) {
Serial.printf("OTA: end failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
uint8_t computedSha[32];
err = esp_ota_get_app_elf_sha256(otaHandle, (char *)computedSha, sizeof(computedSha));
if (err != ESP_OK) {
Serial.printf("OTA: SHA256 read failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
if (memcmp(computedSha, otaExpectedSha, 32) != 0) {
Serial.println("OTA: SHA256 mismatch — aborting");
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
err = esp_ota_set_boot_partition(otaPartition);
if (err != ESP_OK) {
Serial.printf("OTA: set boot partition failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
digitalWrite(LED_PIN, HIGH);
sendOtaNotify(pChar, OTA_STATUS_COMPLETE, 0);
Serial.println("OTA: SHA256 OK, rebooting...");
delay(500);
esp_restart();
return;
}
```
De wijziging: na `esp_ota_end()` wordt `esp_ota_get_app_elf_sha256()` aangeroepen en wordt `memcmp` gedaan. Bij mismatch: ERROR notificatie + `otaReset()` zonder boot partitie te wijzigen.
- [ ] **Step 2: Compileer ESP firmware**
```bash
pio run
```
- [ ] **Step 3: Commit**
```bash
git add src/main.cpp
git commit -m "feat: add SHA256 verification before esp_ota_set_boot_partition"
```
---
### Task 2: Android OTA notification handling
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
ESP stuurt 5-byte notificaties `[status:1][seq:4_BE]` met status `0x00`=ACK, `0x01`=NAK, `0x10`=COMPLETE, `0x11`=ERROR. Android moet deze verwerken met retry-logica.
- [ ] **Step 1: Callback interface toevoegen voor OTA notificaties**
In `BleRelayManager.kt`, voeg een callback interface toe voor OTA notificaties rond de `BleCallbacks` interface (regel 64):
In `BleRelayManager.kt`, voeg toe na de bestaande `BleCallbacks` interface:
```kotlin
interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int)
}
private var otaNotifyCallback: OtaNotifyCallback? = null
fun setOtaNotifyCallback(callback: OtaNotifyCallback?) {
otaNotifyCallback = callback
}
```
- [ ] **Step 2: `onCharacteristicChanged()` toevoegen aan `GattCallback`**
In `BleRelayManager.kt`, voeg `onCharacteristicChanged()` toe in de `GattCallback` class, na `onCharacteristicWrite()` (regel 327):
```kotlin
@SuppressLint("MissingPermission")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val status = value[0].toInt() and 0xFF
val seq = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF)
} else 0
Log.d(TAG, "OTA notify: status=$status seq=$seq")
handler.post { otaNotifyCallback?.onOtaNotify(status, seq) }
}
}
```
- [ ] **Step 3: Retry-logica in `OtaManager.kt`**
Herschrijf `performOta()` in `OtaManager.kt` (regels 54-109) om te wachten op ACK/NAK per chunk:
**Replace de hele `performOta()` method:**
```kotlin
suspend fun performOta(callback: OtaCallback): Boolean = withContext(Dispatchers.IO) {
val fw = firmwareData ?: run {
withContext(Dispatchers.Main) { callback.onError("Firmware niet geladen") }
return@withContext false
}
val sha256 = MessageDigest.getInstance("SHA-256").digest(fw)
var chunkAckReceived = false
var chunkNakReceived = false
var finalStatusReceived = false
var finalStatus = -1
val notifyCallback = object : BleRelayManager.OtaNotifyCallback {
override fun onOtaNotify(status: Int, seq: Int) {
when (status) {
0x00 -> chunkAckReceived = true
0x01 -> chunkNakReceived = true
0x10 -> { finalStatusReceived = true; finalStatus = 0x10 }
0x11 -> { finalStatusReceived = true; finalStatus = 0x11 }
}
}
}
relayManager.setOtaNotifyCallback(notifyCallback)
try {
withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") }
val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN)
startPacket.put(OTA_CMD_START)
startPacket.putInt(fw.size)
relayManager.sendOtaData(startPacket.array())
try {
withTimeout(OTA_START_TIMEOUT_MS) {
while (true) { delay(100); ensureActive() }
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {}
val totalChunks = (fw.size + OTA_CHUNK_DATA_SIZE - 1) / OTA_CHUNK_DATA_SIZE
var seq = 0
var offset = 0
while (offset < fw.size) {
val remaining = fw.size - offset
val chunkSize = minOf(remaining, OTA_CHUNK_DATA_SIZE)
val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN)
packet.putInt(seq)
packet.put(fw, offset, chunkSize)
chunkAckReceived = false
chunkNakReceived = false
var retries = 0
while (retries < 3) {
relayManager.sendOtaData(packet.array())
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
try {
withTimeout(2000L) {
while (!chunkAckReceived && !chunkNakReceived) {
delay(50)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for ACK/NAK on chunk $seq")
}
if (chunkAckReceived) break
retries++
Log.w(TAG, "Chunk $seq NAK/timeout, retry $retries/3")
}
if (!chunkAckReceived) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na 3 pogingen") }
return@withContext false
}
val pct = ((offset.toFloat() / fw.size.toFloat()) * 99).toInt().coerceAtMost(99)
withContext(Dispatchers.Main) {
callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks")
}
offset += chunkSize
seq++
}
withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") }
finalStatusReceived = false
relayManager.sendOtaData(sha256)
try {
withTimeout(10000L) {
while (!finalStatusReceived) {
delay(100)
ensureActive()
}
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Timeout waiting for final OTA status")
}
relayManager.setOtaNotifyCallback(null)
if (finalStatus == 0x10) {
withContext(Dispatchers.Main) {
callback.onProgress(100, "Herstart...")
callback.onComplete()
}
true
} else {
withContext(Dispatchers.Main) {
callback.onError("OTA mislukt: ESP SHA256 verificatie fout")
}
false
}
} catch (e: Exception) {
relayManager.setOtaNotifyCallback(null)
withContext(Dispatchers.Main) { callback.onError("OTA fout: ${e.message}") }
false
}
}
```
- [ ] **Step 4: Compileer Android app**
```bash
./gradlew assembleDebug
```
- [ ] **Step 5: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "feat: OTA notification handling with ACK/NAK retry logic"
```
---
### Task 3: ESP setMTU(512) + Android dynamische chunk size
**Files:**
- Modify: `src/main.cpp` (BLE setup, rond regel 270)
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
- [ ] **Step 1: ESP — zet MTU naar 512**
In `src/main.cpp`, in de `setup()` functie, voeg `NimBLEDevice::setMTU(512)` toe vóór de BLE initialisatie. Zoek `NimBLEDevice::init` (rond regel 275) en voeg de regel ervoor toe:
```cpp
NimBLEDevice::setMTU(512);
NimBLEDevice::init(deviceName);
```
- [ ] **Step 2: ESP — compileer**
```bash
pio run
```
- [ ] **Step 3: Android — MTU opslaan en doorgeven**
In `BleRelayManager.kt`, `onMtuChanged()` (regel 265), sla de MTU op:
Voeg een field toe bij de andere fields (rond regel 59):
```kotlin
private var negotiatedMtu: Int = 23
```
In `onMtuChanged()`:
```kotlin
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
negotiatedMtu = mtu
val service = gatt.getService(SERVICE_UUID)
...
```
Voeg getter toe (na `getEspVersion()`, regel 220):
```kotlin
fun getNegotiatedMtu(): Int = negotiatedMtu
```
- [ ] **Step 4: Android — dynamische chunk size in `OtaManager.kt`**
Vervang `OTA_CHUNK_DATA_SIZE` constant (regel 26) door een dynamische property:
```kotlin
private val OTA_CHUNK_DATA_SIZE: Int
get() {
val mtu = instance?.relayManager?.getNegotiatedMtu() ?: 23
return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK)
}
private const val OTA_ESP_MAX_CHUNK = 500
```
Maar `instance?.relayManager` werkt niet direct in companion. Maak `OtaManager` non-companion eigenschap of sla de chunk size op bij constructie:
In `OtaManager` class body (na companion object, voor `loadFirmware`):
```kotlin
private var chunkDataSize: Int = 248
init {
chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu())
}
private fun calculateChunkSize(mtu: Int): Int {
return minOf(maxOf(mtu - 3 - 4, 20), 500)
}
```
Vervang alle referenties naar `OTA_CHUNK_DATA_SIZE` in `performOta()` naar `chunkDataSize` (komt 3x voor: regel 77, 83, en een impliciete in de chunkSize berekening).
- [ ] **Step 5: Android — compileer**
```bash
./gradlew assembleDebug
```
- [ ] **Step 6: Commit**
```bash
git add src/main.cpp android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "feat: ESP MTU 512 + dynamic Android chunk size"
```
---
### Task 4: writeCharacteristic return check
**Files:**
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt:186-192`
- Modify: `android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt`
- [ ] **Step 1: `sendOtaData()` return waarde toevoegen**
In `BleRelayManager.kt`, wijzig `sendOtaData()` (regel 186) om `Boolean` terug te geven:
```kotlin
@SuppressLint("MissingPermission")
fun sendOtaData(data: ByteArray): Boolean {
val char = otaCharacteristic ?: return false
val gatt = bluetoothGatt ?: return false
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return gatt.writeCharacteristic(char)
}
```
- [ ] **Step 2: Return check in `performOta()`**
In `OtaManager.kt`, in de retry-loop van `performOta()` (de while-loop die chunks stuurt), wijzig de `relayManager.sendOtaData(packet.array())` aanroep:
```kotlin
while (retries < 3) {
val writeOk = relayManager.sendOtaData(packet.array())
if (!writeOk) {
Log.w(TAG, "writeCharacteristic returned false for chunk $seq, retrying")
retries++
delay(100)
continue
}
Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}")
...
```
- [ ] **Step 3: Compileer**
```bash
./gradlew assembleDebug
```
- [ ] **Step 4: Commit**
```bash
git add android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
git commit -m "fix: check writeCharacteristic return value in sendOtaData"
```
---
### Task 5: otaStatusText widget verwijderen
**Files:**
- Modify: `android/app/src/main/res/layout/activity_settings.xml:39-47`
- [ ] **Step 1: Verwijder `otaStatusText` uit layout**
In `activity_settings.xml`, verwijder regels 39-47 (de `TextView` met id `otaStatusText`).
- [ ] **Step 2: Compileer en verifieer dat er geen referenties meer zijn**
```bash
./gradlew assembleDebug
```
Bevestig met grep dat er nergens meer `otaStatusText` voorkomt:
```bash
grep -r "otaStatusText" android/
```
Output moet leeg zijn.
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/res/layout/activity_settings.xml
git commit -m "chore: remove unused otaStatusText widget"
```
---
### Task 6: Eindverificatie — alles compileren en OTA testen
- [ ] **Step 1: ESP firmware compileren**
```bash
pio run
```
Expected: SUCCESS
- [ ] **Step 2: Android app compileren**
```bash
./gradlew assembleDebug
```
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: ESP flashen via USB**
```bash
pio run -t upload
```
- [ ] **Step 4: OTA testen via BLE**
1. Open app op telefoon, verbind met SylDa_Server
2. Ga naar Settings, selecteer Bluetooth
3. Klik "Module bijwerken"
4. Verify: progress bar vult, geen foutmeldingen
5. Verify: ESP reboot en toont v5 in versie
- [ ] **Step 5: Commit firmware.bin (indien build hash veranderd)**
```bash
cp .pio/build/esp32-s3-devkitc-1/firmware.bin android/app/src/main/assets/firmware/firmware.bin
git add android/app/src/main/assets/firmware/firmware.bin
git commit -m "chore: update bundled firmware.bin"
```

View file

@ -0,0 +1,128 @@
# 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 `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
- 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
- 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
```
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:** 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
523KB bij ~30 KB/s effectieve BLE throughput ≈ 17 seconden. Bij slechtere verbinding ~45-60 seconden. Acceptabel.
## Bestanden
| Bestand | Wijziging |
|---|---|
| `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 |

View file

@ -0,0 +1,90 @@
# OTA Improvements — post code review
Datum: 2026-07-30
Branch: `feature/ble-ota`
## Doel
Zes code review aanbevelingen implementeren voor robuustere OTA firmware update, in volgorde van prioriteit.
---
## 1. ESP SHA256 verificatie
### ESP (`src/main.cpp`)
In `onWrite()` voor de BLE OTA characteristic:
- Android stuurt SHA256 hash van `firmware.bin` als laatste chunk (32 bytes, `chunkIndex == totalChunks`)
- ESP herkent laatste chunk aan `dataLen == 32` in combinatie met `chunkIndex >= totalChunks`
- Bij ontvangst: `esp_ota_end(update_handle)` afronden, dan `esp_ota_get_app_elf_sha256(update_handle, computedSha)` aanroepen
- `memcmp(computedSha, expectedSha, 32)` — bij mismatch:
- abortOta(update_handle) → bestaande partitie blijft actief
- NAK-notificatie sturen naar Android (0x01)
- Bij match:
- ACK-notificatie sturen (0x00)
- `esp_ota_set_boot_partition(update_handle)`
- `esp_restart()`
### Android (`OtaManager.kt`)
- Na laatste data-chunk: SHA256 van `firmware.bin` berekenen met `java.security.MessageDigest.getInstance("SHA-256")`
- 32-byte hash als aparte chunk sturen met `chunkIndex = totalChunks + 1`
- Wachten op ACK/NAK response van ESP (punt 2)
---
## 2. OTA notification handling (Android)
### `BleRelayManager.kt`
- `onCharacteristicChanged()` in OTA callback doorsturen naar `OtaManager.otaNotificationHandler(bytes: ByteArray)`
### `OtaManager.kt`
- `otaNotificationHandler(bytes)`: leest `bytes[0]` — `0x00`=ACK, `0x01`=NAK
- ACK → `sendNextChunk()`, update progress
- NAK → retry huidige chunk (max 3x per chunk)
- Timeout: na versturen chunk, 2s timer — geen antwoord = retry (max 3x)
- Bij overschrijding max retries: OTA abort met foutmelding in dialog
---
## 3+4. Dynamische chunk size + setMTU(512)
### ESP
- `NimBLEDevice::setMTU(512)` aanroepen in `setupBLE()`
### Android
- `OTA_CHUNK_DATA_SIZE` vervangen door `chunkDataSize` variabele
- `onMtuChanged(mtu: Int)` callback → `chunkDataSize = mtu - 3 - 4` (ATT header + chunk header)
- Default: 248 (valt terug als MTU nog niet onderhandeld is)
- Chunk header blijft 4 bytes: `[chunkIndex:2][totalChunks:2]`
---
## 5. writeCharacteristic return check
### `OtaManager.kt`
In `sendOtaData()`:
```kotlin
val success = bleRelayManager.writeOtaCharacteristic(chunk)
if (!success) {
retriesLeft--
if (retriesLeft <= 0) abortOta()
else retryCurrentChunk()
}
```
---
## 6. otaStatusText widget verwijderen
- `otaStatusText` verwijderen uit `activity_settings.xml`
- `otaStatusText` property verwijderen uit `SettingsActivity.kt`
De progress bar in de OTA dialog toont al percentage — aparte statuswidget is overbodig.

View file

@ -3,7 +3,11 @@
#include <NimBLEAdvertising.h>
#include <mbedtls/md.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <nvs_flash.h>
#define RELAY1_PIN 1
#define RELAY2_PIN 2
@ -15,14 +19,23 @@
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define HMAC_PKT_SIZE 37 // 1 cmd + 4 seq + 32 hmac
#define HMAC_PKT_SIZE 37
#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 // 1 cmd + 4 seq + 32 hmac
#define HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key"
#define NVS_UUID_NAME "uuid"
#define FIRMWARE_VERSION "1"
#define FIRMWARE_VERSION "5"
NimBLECharacteristic *pCharacteristic;
bool deviceConnected = false;
@ -32,6 +45,15 @@ bool relay1State = false;
bool relay2State = false;
uint32_t lastSeq = 0;
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];
mbedtls_sha256_context otaShaCtx;
bool otaShaStarted = false;
uint8_t hmacKey[HMAC_KEY_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool keyProvisioned = false;
@ -100,8 +122,140 @@ static void parseHexKey(const String &hex, uint8_t *out) {
}
}
static void ledOtaBusy() {
digitalWrite(LED_PIN, (millis() / 100) % 2 ? HIGH : LOW);
}
static void otaReset() {
otaInProgress = false;
otaHandle = 0;
otaPartition = nullptr;
otaExpectedSize = 0;
otaWritten = 0;
if (otaShaStarted) {
mbedtls_sha256_free(&otaShaCtx);
otaShaStarted = false;
}
}
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);
}
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;
mbedtls_sha256_init(&otaShaCtx);
mbedtls_sha256_starts_ret(&otaShaCtx, 0);
otaShaStarted = true;
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);
uint8_t computedSha[32];
mbedtls_sha256_finish_ret(&otaShaCtx, computedSha);
mbedtls_sha256_free(&otaShaCtx);
otaShaStarted = false;
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;
}
if (memcmp(computedSha, otaExpectedSha, 32) != 0) {
Serial.println("OTA: SHA256 mismatch — aborting");
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
err = esp_ota_set_boot_partition(otaPartition);
if (err != ESP_OK) {
Serial.printf("OTA: set boot partition failed (%d)\n", err);
otaReset();
sendOtaNotify(pChar, OTA_STATUS_ERROR, 0);
return;
}
digitalWrite(LED_PIN, HIGH);
sendOtaNotify(pChar, OTA_STATUS_COMPLETE, 0);
Serial.println("OTA: SHA256 OK, rebooting...");
delay(500);
esp_restart();
return;
}
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;
mbedtls_sha256_update_ret(&otaShaCtx, data + 4, dataLen);
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);
}
}
};
class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
std::string value = pChar->getValue();
if (value.length() != HMAC_PKT_SIZE) {
Serial.printf("BLE: bad packet length %d (expected %d)\n",
@ -184,6 +338,7 @@ void setup() {
}
Serial.println("LED blink done, starting BLE...");
NimBLEDevice::setMTU(512);
NimBLEDevice::init("SylDa_Server");
NimBLEServer *pServer = NimBLEDevice::createServer();
@ -202,6 +357,18 @@ void setup() {
);
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
NimBLECharacteristic *pVersionChar = pService->createCharacteristic(
VERSION_UUID,
NIMBLE_PROPERTY::READ
);
pVersionChar->setValue(FIRMWARE_VERSION);
NimBLECharacteristic *pOtaChar = pService->createCharacteristic(
OTA_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server");
pAdvertising->addServiceUUID(SERVICE_UUID);
@ -231,7 +398,7 @@ void setup() {
}
void loop() {
if (Serial.available()) {
if (!otaInProgress && Serial.available()) {
static String serialBuf;
char c = Serial.read();
serialBuf += c;
@ -305,8 +472,14 @@ void loop() {
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
relay2State = t2Active || (deviceConnected && bleRelay2);
if (!otaInProgress) {
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
relay2State = t2Active || (deviceConnected && bleRelay2);
} else {
relay1State = false;
relay2State = false;
ledOtaBusy();
}
digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
@ -329,7 +502,7 @@ void loop() {
}
static uint32_t heartbeat = 0;
if (millis() - heartbeat > 5000) {
if (!otaInProgress && millis() - heartbeat > 5000) {
Serial.printf("[heartbeat] R1=%s R2=%s t1=%s t2=%s btn=%s ble1=%s ble2=%s\n",
relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF",