Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a1eff6fa | ||
|
|
69c4ee040d | ||
|
|
44297d30ad | ||
|
|
fcbf1e9b5f | ||
|
|
475c029512 | ||
|
|
ea8bab4571 | ||
|
|
8a54367e30 | ||
|
|
b1591b7bdb | ||
|
|
0513e7777d | ||
|
|
e05d57a32c | ||
|
|
7e45cb4c56 | ||
|
|
57ef68fcab | ||
|
|
1499d840ce | ||
|
|
7607361019 | ||
|
|
1988ae3898 | ||
|
|
675504cbf5 | ||
|
|
2ab1c83655 | ||
|
|
5235057b83 | ||
|
|
6042eceeea | ||
|
|
3e880c9635 | ||
|
|
a1b0daee37 | ||
|
|
d34dfec1ce | ||
|
|
5ed27a9b42 | ||
|
|
6df65d9adf | ||
|
|
5524f173ac |
14 changed files with 2236 additions and 32 deletions
Binary file not shown.
|
|
@ -23,23 +23,27 @@ import java.nio.ByteOrder
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.crypto.Mac
|
import javax.crypto.Mac
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.text.Charsets
|
||||||
|
|
||||||
class BleRelayManager(
|
class BleRelayManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val callbacks: BleCallbacks
|
private val callbacks: BleCallbacks
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
var instance: BleRelayManager? = null
|
||||||
private const val TAG = "BleRelayManager"
|
private const val TAG = "BleRelayManager"
|
||||||
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
|
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 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 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 SCAN_TIMEOUT_MS = 30000L
|
||||||
private const val RECONNECT_DELAY_MS = 2000L
|
private const val RECONNECT_DELAY_MS = 2000L
|
||||||
private const val MAX_SCAN_RETRIES = 3
|
private const val MAX_SCAN_RETRIES = 3
|
||||||
private const val HMAC_PKT_SIZE = 37
|
private const val HMAC_PKT_SIZE = 37
|
||||||
private const val HMAC_LEN = 32
|
private const val HMAC_LEN = 32
|
||||||
private const val BLE_MTU = 256
|
private const val BLE_MTU = 512
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bluetoothGatt: BluetoothGatt? = null
|
private var bluetoothGatt: BluetoothGatt? = null
|
||||||
|
|
@ -52,6 +56,24 @@ class BleRelayManager(
|
||||||
private var hmacKey: ByteArray? = null
|
private var hmacKey: ByteArray? = null
|
||||||
private var deviceUuid: ByteArray? = null
|
private var deviceUuid: ByteArray? = null
|
||||||
private var triedAddresses = mutableSetOf<String>()
|
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 val scanRunnable = Runnable { startScan() }
|
||||||
private var scanTimeoutRunnable: Runnable? = null
|
private var scanTimeoutRunnable: Runnable? = null
|
||||||
|
|
||||||
|
|
@ -170,6 +192,26 @@ class BleRelayManager(
|
||||||
stopScan()
|
stopScan()
|
||||||
bluetoothGatt?.disconnect()
|
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() {
|
fun updateKeys() {
|
||||||
loadState()
|
loadState()
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +224,14 @@ class BleRelayManager(
|
||||||
return bluetoothGatt != null
|
return bluetoothGatt != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAppVersion(version: String) {
|
||||||
|
appVersion = version
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEspVersion(): String? = espVersion
|
||||||
|
|
||||||
|
fun getNegotiatedMtu(): Int = negotiatedMtu
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
|
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
|
||||||
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
|
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
|
||||||
|
|
@ -227,10 +277,13 @@ class BleRelayManager(
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
|
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
|
||||||
Log.d(TAG, "MTU changed to $mtu")
|
Log.d(TAG, "MTU changed to $mtu")
|
||||||
|
negotiatedMtu = mtu
|
||||||
val service = gatt.getService(SERVICE_UUID)
|
val service = gatt.getService(SERVICE_UUID)
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
|
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
|
||||||
|
otaCharacteristic = service.getCharacteristic(OTA_UUID)
|
||||||
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
|
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
|
||||||
|
val versionChar = service.getCharacteristic(VERSION_UUID)
|
||||||
if (relayCharacteristic != null && uuidChar != null) {
|
if (relayCharacteristic != null && uuidChar != null) {
|
||||||
Log.d(TAG, "Reading device UUID characteristic...")
|
Log.d(TAG, "Reading device UUID characteristic...")
|
||||||
gatt.readCharacteristic(uuidChar)
|
gatt.readCharacteristic(uuidChar)
|
||||||
|
|
@ -249,14 +302,38 @@ class BleRelayManager(
|
||||||
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
|
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
val storedUuid = deviceUuid
|
val storedUuid = deviceUuid
|
||||||
if (storedUuid != null && value.contentEquals(storedUuid)) {
|
if (storedUuid != null && value.contentEquals(storedUuid)) {
|
||||||
Log.d(TAG, "UUID verified, setting up relay")
|
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) }
|
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 {
|
} else {
|
||||||
Log.w(TAG, "UUID mismatch -- disconnecting")
|
Log.w(TAG, "UUID mismatch -- disconnecting")
|
||||||
triedAddresses.add(gatt.device.address)
|
triedAddresses.add(gatt.device.address)
|
||||||
handler.post { callbacks.onStatusChanged(BleStatus.WrongDevice) }
|
handler.post { callbacks.onStatusChanged(BleStatus.WrongDevice) }
|
||||||
gatt.disconnect()
|
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) }
|
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() {
|
private fun loadState() {
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,5 @@ enum class BleStatus {
|
||||||
|
|
||||||
interface BleCallbacks {
|
interface BleCallbacks {
|
||||||
fun onStatusChanged(status: BleStatus)
|
fun onStatusChanged(status: BleStatus)
|
||||||
|
fun onFirmwareVersion(espVersion: String, appVersion: String)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
|
@ -22,6 +22,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
|
||||||
private const val REQUEST_SETTINGS = 1
|
private const val REQUEST_SETTINGS = 1
|
||||||
private const val POST_SETTINGS_RESCAN_MS = 500L
|
private const val POST_SETTINGS_RESCAN_MS = 500L
|
||||||
private const val ON_RESUME_SCAN_DELAY_MS = 300L
|
private const val ON_RESUME_SCAN_DELAY_MS = 300L
|
||||||
|
private const val BUNDLED_FIRMWARE_VERSION = "5"
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var manager: BleRelayManager
|
private lateinit var manager: BleRelayManager
|
||||||
|
|
@ -90,13 +91,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
manager = BleRelayManager(this, this)
|
manager = BleRelayManager(this, this)
|
||||||
|
manager.setAppVersion(BUNDLED_FIRMWARE_VERSION)
|
||||||
|
|
||||||
statusText = findViewById(R.id.statusText)
|
statusText = findViewById(R.id.statusText)
|
||||||
relay1Button = findViewById(R.id.relay1Button)
|
relay1Button = findViewById(R.id.relay1Button)
|
||||||
relay2Button = findViewById(R.id.relay2Button)
|
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)
|
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
|
||||||
}
|
}
|
||||||
setupRelayButton()
|
setupRelayButton()
|
||||||
|
|
@ -217,4 +219,16 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
|
||||||
handler.removeCallbacksAndMessages(null)
|
handler.removeCallbacksAndMessages(null)
|
||||||
manager.destroy()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
184
android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
Normal file
184
android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
private const val REBOOT_WAIT_MS = 5000L
|
private const val REBOOT_WAIT_MS = 5000L
|
||||||
private const val REBOOT_POLL_MS = 500L
|
private const val REBOOT_POLL_MS = 500L
|
||||||
private const val REBOOT_MAX_WAIT_MS = 15000L
|
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 }
|
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
|
||||||
|
|
@ -74,7 +74,11 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
private var progressDialog: AlertDialog? = null
|
private var progressDialog: AlertDialog? = null
|
||||||
private var dialog: AlertDialog? = null
|
private var dialog: AlertDialog? = null
|
||||||
private var flasherJob: Job? = null
|
private var flasherJob: Job? = null
|
||||||
|
private var otaManager: OtaManager? = null
|
||||||
|
private var otaJob: Job? = null
|
||||||
private var lastTheme: String? = null
|
private var lastTheme: String? = null
|
||||||
|
private val rebootTimeoutHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var rebootTimeoutRunnable: Runnable? = null
|
||||||
|
|
||||||
private val usbAttachedReceiver = object : BroadcastReceiver() {
|
private val usbAttachedReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
|
@ -136,8 +140,12 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
supportActionBar?.title = getString(R.string.title_settings)
|
supportActionBar?.title = getString(R.string.title_settings)
|
||||||
|
|
||||||
findViewById<Button>(R.id.flashFirmwareButton).setOnClickListener {
|
findViewById<Button>(R.id.updateModuleButton).setOnClickListener {
|
||||||
|
if (findViewById<RadioGroup>(R.id.flashMethodGroup).checkedRadioButtonId == R.id.methodUsb) {
|
||||||
startFlashing()
|
startFlashing()
|
||||||
|
} else {
|
||||||
|
startOtaUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
|
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() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
|
||||||
|
rebootTimeoutRunnable = null
|
||||||
currentAction = UsbAction.NONE
|
currentAction = UsbAction.NONE
|
||||||
flasherJob?.cancel()
|
flasherJob?.cancel()
|
||||||
flasherJob = null
|
flasherJob = null
|
||||||
|
otaJob?.cancel()
|
||||||
|
otaJob = null
|
||||||
try { unregisterReceiver(usbAttachedReceiver) } catch (_: Exception) {}
|
try { unregisterReceiver(usbAttachedReceiver) } catch (_: Exception) {}
|
||||||
try { unregisterReceiver(usbPermissionReceiver) } catch (_: Exception) {}
|
try { unregisterReceiver(usbPermissionReceiver) } catch (_: Exception) {}
|
||||||
try { unregisterReceiver(usbDetachedReceiver) } catch (_: Exception) {}
|
try { unregisterReceiver(usbDetachedReceiver) } catch (_: Exception) {}
|
||||||
|
|
@ -331,9 +410,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val version = readEspVersion(usbManager, device)
|
val version = readEspVersion(usbManager, device)
|
||||||
if (version == EXPECTED_FIRMWARE_VERSION && !expectingReboot) {
|
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?.dismiss()
|
||||||
progressDialog = null
|
progressDialog = null
|
||||||
|
Toast.makeText(this@SettingsActivity, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
|
||||||
currentAction = UsbAction.EXCHANGE_KEY
|
currentAction = UsbAction.EXCHANGE_KEY
|
||||||
doKeyExchange(usbManager, device)
|
doKeyExchange(usbManager, device)
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -372,6 +452,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
getString(R.string.title_firmware_flash),
|
getString(R.string.title_firmware_flash),
|
||||||
getString(R.string.msg_env_waiting_reboot)
|
getString(R.string.msg_env_waiting_reboot)
|
||||||
)
|
)
|
||||||
|
rebootTimeoutRunnable = Runnable { cleanupAction() }
|
||||||
|
rebootTimeoutHandler.postDelayed(rebootTimeoutRunnable!!, REBOOT_MAX_WAIT_MS)
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
cleanupAction()
|
cleanupAction()
|
||||||
throw e
|
throw e
|
||||||
|
|
@ -388,6 +470,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
// === USB dispatch ===
|
// === USB dispatch ===
|
||||||
|
|
||||||
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
|
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
|
||||||
|
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
|
||||||
|
rebootTimeoutRunnable = null
|
||||||
when (currentAction) {
|
when (currentAction) {
|
||||||
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
|
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
|
||||||
UsbAction.FLASH -> doFlashing(usbManager, device)
|
UsbAction.FLASH -> doFlashing(usbManager, device)
|
||||||
|
|
@ -436,6 +520,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
private fun cleanupAction() {
|
private fun cleanupAction() {
|
||||||
currentAction = UsbAction.NONE
|
currentAction = UsbAction.NONE
|
||||||
expectingReboot = false
|
expectingReboot = false
|
||||||
|
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
|
||||||
|
rebootTimeoutRunnable = null
|
||||||
dialog?.dismiss()
|
dialog?.dismiss()
|
||||||
dialog = null
|
dialog = null
|
||||||
progressDialog?.dismiss()
|
progressDialog?.dismiss()
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/settingsContainer"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="end|top"
|
android:layout_gravity="end|top"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,36 @@
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="24dp">
|
android:padding="24dp">
|
||||||
|
|
||||||
<TextView
|
<RadioGroup
|
||||||
|
android:id="@+id/flashMethodGroup"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/msg_flash_mode_help"
|
android:orientation="horizontal"
|
||||||
android:textSize="14sp"
|
android:layout_marginBottom="8dp"
|
||||||
android:layout_marginBottom="16dp" />
|
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
|
<Button
|
||||||
android:id="@+id/flashFirmwareButton"
|
android:id="@+id/updateModuleButton"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/button_firmware_flash" />
|
android:text="@string/button_update_module" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@
|
||||||
|
|
||||||
<!-- SettingsActivity -->
|
<!-- SettingsActivity -->
|
||||||
<string name="title_settings">Instellingen</string>
|
<string name="title_settings">Instellingen</string>
|
||||||
<string name="msg_settings_intro">Sluit de ESP aan op USB en kies een actie.</string>
|
<string name="method_bluetooth">Bluetooth</string>
|
||||||
<string name="msg_flash_mode_help">Sluit de module aan op de USB-poort.</string>
|
<string name="method_usb">USB</string>
|
||||||
<string name="button_firmware_flash">Firmware bijwerken</string>
|
<string name="button_update_module">Module bijwerken</string>
|
||||||
<string name="usb_access_denied">USB-toegang geweigerd</string>
|
<string name="usb_access_denied">USB-toegang geweigerd</string>
|
||||||
<string name="key_read_success">Beveiligingssleutel gelezen</string>
|
<string name="key_read_success">Beveiligingssleutel gelezen</string>
|
||||||
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</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_light">Licht</string>
|
||||||
<string name="theme_dark">Donker</string>
|
<string name="theme_dark">Donker</string>
|
||||||
<string name="theme_applied">Thema: %s</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>
|
</resources>
|
||||||
|
|
|
||||||
900
docs/superpowers/plans/2026-07-30-ble-ota.md
Normal file
900
docs/superpowers/plans/2026-07-30-ble-ota.md
Normal 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"
|
||||||
|
```
|
||||||
500
docs/superpowers/plans/2026-07-30-ota-improvements.md
Normal file
500
docs/superpowers/plans/2026-07-30-ota-improvements.md
Normal 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"
|
||||||
|
```
|
||||||
128
docs/superpowers/specs/2026-07-30-ble-ota-design.md
Normal file
128
docs/superpowers/specs/2026-07-30-ble-ota-design.md
Normal 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 |
|
||||||
90
docs/superpowers/specs/2026-07-30-ota-improvements-design.md
Normal file
90
docs/superpowers/specs/2026-07-30-ota-improvements-design.md
Normal 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.
|
||||||
181
src/main.cpp
181
src/main.cpp
|
|
@ -3,7 +3,11 @@
|
||||||
#include <NimBLEAdvertising.h>
|
#include <NimBLEAdvertising.h>
|
||||||
|
|
||||||
#include <mbedtls/md.h>
|
#include <mbedtls/md.h>
|
||||||
|
#include <mbedtls/sha256.h>
|
||||||
#include <Preferences.h>
|
#include <Preferences.h>
|
||||||
|
#include <esp_ota_ops.h>
|
||||||
|
#include <esp_partition.h>
|
||||||
|
#include <nvs_flash.h>
|
||||||
|
|
||||||
#define RELAY1_PIN 1
|
#define RELAY1_PIN 1
|
||||||
#define RELAY2_PIN 2
|
#define RELAY2_PIN 2
|
||||||
|
|
@ -15,14 +19,23 @@
|
||||||
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
|
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
|
||||||
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
|
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
|
||||||
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
|
#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 HMAC_KEY_LEN 32
|
||||||
#define ESP_UUID_LEN 16
|
#define ESP_UUID_LEN 16
|
||||||
#define NVS_NAMESPACE "hmac"
|
#define NVS_NAMESPACE "hmac"
|
||||||
#define NVS_KEY_NAME "key"
|
#define NVS_KEY_NAME "key"
|
||||||
#define NVS_UUID_NAME "uuid"
|
#define NVS_UUID_NAME "uuid"
|
||||||
#define FIRMWARE_VERSION "1"
|
#define FIRMWARE_VERSION "5"
|
||||||
|
|
||||||
NimBLECharacteristic *pCharacteristic;
|
NimBLECharacteristic *pCharacteristic;
|
||||||
bool deviceConnected = false;
|
bool deviceConnected = false;
|
||||||
|
|
@ -32,6 +45,15 @@ bool relay1State = false;
|
||||||
bool relay2State = false;
|
bool relay2State = false;
|
||||||
uint32_t lastSeq = 0;
|
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 hmacKey[HMAC_KEY_LEN];
|
||||||
uint8_t deviceUuid[ESP_UUID_LEN];
|
uint8_t deviceUuid[ESP_UUID_LEN];
|
||||||
bool keyProvisioned = false;
|
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 {
|
class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
|
||||||
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
|
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
|
||||||
|
if (otaInProgress) return;
|
||||||
std::string value = pChar->getValue();
|
std::string value = pChar->getValue();
|
||||||
if (value.length() != HMAC_PKT_SIZE) {
|
if (value.length() != HMAC_PKT_SIZE) {
|
||||||
Serial.printf("BLE: bad packet length %d (expected %d)\n",
|
Serial.printf("BLE: bad packet length %d (expected %d)\n",
|
||||||
|
|
@ -184,6 +338,7 @@ void setup() {
|
||||||
}
|
}
|
||||||
Serial.println("LED blink done, starting BLE...");
|
Serial.println("LED blink done, starting BLE...");
|
||||||
|
|
||||||
|
NimBLEDevice::setMTU(512);
|
||||||
NimBLEDevice::init("SylDa_Server");
|
NimBLEDevice::init("SylDa_Server");
|
||||||
|
|
||||||
NimBLEServer *pServer = NimBLEDevice::createServer();
|
NimBLEServer *pServer = NimBLEDevice::createServer();
|
||||||
|
|
@ -202,6 +357,18 @@ void setup() {
|
||||||
);
|
);
|
||||||
pUuidChar->setValue(deviceUuid, ESP_UUID_LEN);
|
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();
|
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
|
||||||
pAdvertising->setName("SylDa_Server");
|
pAdvertising->setName("SylDa_Server");
|
||||||
pAdvertising->addServiceUUID(SERVICE_UUID);
|
pAdvertising->addServiceUUID(SERVICE_UUID);
|
||||||
|
|
@ -231,7 +398,7 @@ void setup() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
if (Serial.available()) {
|
if (!otaInProgress && Serial.available()) {
|
||||||
static String serialBuf;
|
static String serialBuf;
|
||||||
char c = Serial.read();
|
char c = Serial.read();
|
||||||
serialBuf += c;
|
serialBuf += c;
|
||||||
|
|
@ -305,8 +472,14 @@ void loop() {
|
||||||
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
|
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
|
||||||
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
|
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
|
||||||
|
|
||||||
|
if (!otaInProgress) {
|
||||||
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
|
relay1State = t1Active || btnActive || (deviceConnected && bleRelay1);
|
||||||
relay2State = t2Active || (deviceConnected && bleRelay2);
|
relay2State = t2Active || (deviceConnected && bleRelay2);
|
||||||
|
} else {
|
||||||
|
relay1State = false;
|
||||||
|
relay2State = false;
|
||||||
|
ledOtaBusy();
|
||||||
|
}
|
||||||
|
|
||||||
digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
|
digitalWrite(RELAY1_PIN, relay1State ? HIGH : LOW);
|
||||||
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
|
digitalWrite(RELAY2_PIN, relay2State ? HIGH : LOW);
|
||||||
|
|
@ -329,7 +502,7 @@ void loop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static uint32_t heartbeat = 0;
|
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",
|
Serial.printf("[heartbeat] R1=%s R2=%s t1=%s t2=%s btn=%s ble1=%s ble2=%s\n",
|
||||||
relay1State ? "ON" : "OFF",
|
relay1State ? "ON" : "OFF",
|
||||||
relay2State ? "ON" : "OFF",
|
relay2State ? "ON" : "OFF",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue