fix: USB/OTA update flow - version check, chunk size, UX improvements

- MainActivity: suppress toast when activity not resumed, auto-navigate to
  Settings on new firmware
- SettingsActivity: unified BLE/USB radio buttons + single update button,
  version check before OTA, show dialog before loadFirmware, spinner timeout
- BleRelayManager: expose getEspVersion()
- OtaManager: reduce chunk size 500→248 to fit BLE MTU 255
- ESP: WRITE→WRITE_NR on OTA characteristic (was silently dropping data)
- Layout/strings: remove intro text, add method radio buttons
- Bump all firmware versions to v5
This commit is contained in:
eddy 2026-07-30 18:59:08 +02:00
commit 0513e7777d
8 changed files with 67 additions and 32 deletions

View file

@ -217,6 +217,8 @@ class BleRelayManager(
appVersion = version appVersion = version
} }
fun getEspVersion(): String? = espVersion
@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) }

View file

@ -22,7 +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 = "2" private const val BUNDLED_FIRMWARE_VERSION = "5"
} }
private lateinit var manager: BleRelayManager private lateinit var manager: BleRelayManager
@ -222,8 +222,10 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
override fun onFirmwareVersion(espVersion: String, appVersion: String) { override fun onFirmwareVersion(espVersion: String, appVersion: String) {
runOnUiThread { runOnUiThread {
if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread
if (espVersion.isEmpty() || espVersion != appVersion) { if (espVersion.isEmpty() || espVersion != appVersion) {
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_LONG).show() Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_SHORT).show()
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
} else { } else {
Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
} }

View file

@ -23,7 +23,7 @@ class OtaManager(
) { ) {
companion object { companion object {
private const val TAG = "OtaManager" private const val TAG = "OtaManager"
private const val OTA_CHUNK_DATA_SIZE = 500 private const val OTA_CHUNK_DATA_SIZE = 248
private const val OTA_CMD_START: Byte = 0x10 private const val OTA_CMD_START: Byte = 0x10
private const val OTA_CMD_END: Byte = 0x11 private const val OTA_CMD_END: Byte = 0x11
private const val OTA_STATUS_ACK: Byte = 0x00 private const val OTA_STATUS_ACK: Byte = 0x00

View file

@ -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 }
@ -77,6 +77,8 @@ class SettingsActivity : AppCompatActivity() {
private var otaManager: OtaManager? = null private var otaManager: OtaManager? = null
private var otaJob: Job? = 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) {
@ -138,13 +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 {
startFlashing() if (findViewById<RadioGroup>(R.id.flashMethodGroup).checkedRadioButtonId == R.id.methodUsb) {
} startFlashing()
} else {
val otaButton = findViewById<Button>(R.id.otaUpdateButton) startOtaUpdate()
otaButton.setOnClickListener { }
startOtaUpdate()
} }
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED) registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
@ -219,15 +220,26 @@ class SettingsActivity : AppCompatActivity() {
Toast.makeText(this, getString(R.string.ota_needs_usb_first), Toast.LENGTH_LONG).show() Toast.makeText(this, getString(R.string.ota_needs_usb_first), Toast.LENGTH_LONG).show()
return return
} }
otaManager = OtaManager(this, manager)
if (otaManager?.loadFirmware() != true) { val espVersion = manager.getEspVersion()
Toast.makeText(this, getString(R.string.ota_load_failed), Toast.LENGTH_SHORT).show() if (espVersion != null && espVersion == EXPECTED_FIRMWARE_VERSION) {
Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
return return
} }
showOtaProgressDialog() 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 { otaJob = lifecycleScope.launch {
val result = otaManager!!.performOta(object : OtaManager.OtaCallback { val result = otaManager!!.performOta(object : OtaManager.OtaCallback {
override fun onProgress(percent: Int, message: String) { override fun onProgress(percent: Int, message: String) {
@ -267,6 +279,8 @@ class SettingsActivity : AppCompatActivity() {
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
@ -396,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
@ -437,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
@ -453,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)
@ -501,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()

View file

@ -5,25 +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" />
<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 <TextView
android:id="@+id/otaStatusText" android:id="@+id/otaStatusText"

View file

@ -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>
@ -104,8 +104,7 @@
<string name="theme_applied">Thema: %s</string> <string name="theme_applied">Thema: %s</string>
<!-- OTA Updates --> <!-- OTA Updates -->
<string name="button_ota_update">Firmware bijwerken via Bluetooth</string> <string name="title_ota_update">Module bijwerken</string>
<string name="title_ota_update">OTA Firmware Update</string>
<string name="msg_ota_starting">OTA update starten...</string> <string name="msg_ota_starting">OTA update starten...</string>
<string name="ota_update_available">Nieuwe firmware beschikbaar</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_up_to_date">Firmware is up‑to‑date</string>

View file

@ -34,7 +34,7 @@
#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 "2" #define FIRMWARE_VERSION "5"
NimBLECharacteristic *pCharacteristic; NimBLECharacteristic *pCharacteristic;
bool deviceConnected = false; bool deviceConnected = false;
@ -343,7 +343,7 @@ void setup() {
NimBLECharacteristic *pOtaChar = pService->createCharacteristic( NimBLECharacteristic *pOtaChar = pService->createCharacteristic(
OTA_UUID, OTA_UUID,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::NOTIFY NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
); );
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks()); pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());