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
}
fun getEspVersion(): String? = espVersion
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
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 POST_SETTINGS_RESCAN_MS = 500L
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
@ -222,8 +222,10 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
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_LONG).show()
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_SHORT).show()
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
} else {
Toast.makeText(this, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
}

View file

@ -23,7 +23,7 @@ class OtaManager(
) {
companion object {
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_END: Byte = 0x11
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_POLL_MS = 500L
private const val REBOOT_MAX_WAIT_MS = 15000L
private const val EXPECTED_FIRMWARE_VERSION = "1"
private const val EXPECTED_FIRMWARE_VERSION = "5"
}
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
@ -77,6 +77,8 @@ class SettingsActivity : AppCompatActivity() {
private var otaManager: OtaManager? = null
private var otaJob: Job? = null
private var lastTheme: String? = null
private val rebootTimeoutHandler = Handler(Looper.getMainLooper())
private var rebootTimeoutRunnable: Runnable? = null
private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@ -138,14 +140,13 @@ class SettingsActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
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()
}
val otaButton = findViewById<Button>(R.id.otaUpdateButton)
otaButton.setOnClickListener {
} else {
startOtaUpdate()
}
}
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED)
@ -219,15 +220,26 @@ class SettingsActivity : AppCompatActivity() {
Toast.makeText(this, getString(R.string.ota_needs_usb_first), Toast.LENGTH_LONG).show()
return
}
otaManager = OtaManager(this, manager)
if (otaManager?.loadFirmware() != true) {
Toast.makeText(this, getString(R.string.ota_load_failed), Toast.LENGTH_SHORT).show()
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) {
@ -267,6 +279,8 @@ class SettingsActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
currentAction = UsbAction.NONE
flasherJob?.cancel()
flasherJob = null
@ -396,9 +410,10 @@ class SettingsActivity : AppCompatActivity() {
lifecycleScope.launch {
val version = readEspVersion(usbManager, device)
if (version == EXPECTED_FIRMWARE_VERSION && !expectingReboot) {
Log.d(TAG, "Firmware version $version matches, skipping flash")
Log.d(TAG, "Firmware version $version matches, up to date")
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.ota_up_to_date), Toast.LENGTH_SHORT).show()
currentAction = UsbAction.EXCHANGE_KEY
doKeyExchange(usbManager, device)
return@launch
@ -437,6 +452,8 @@ class SettingsActivity : AppCompatActivity() {
getString(R.string.title_firmware_flash),
getString(R.string.msg_env_waiting_reboot)
)
rebootTimeoutRunnable = Runnable { cleanupAction() }
rebootTimeoutHandler.postDelayed(rebootTimeoutRunnable!!, REBOOT_MAX_WAIT_MS)
} catch (e: kotlinx.coroutines.CancellationException) {
cleanupAction()
throw e
@ -453,6 +470,8 @@ class SettingsActivity : AppCompatActivity() {
// === USB dispatch ===
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
when (currentAction) {
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
UsbAction.FLASH -> doFlashing(usbManager, device)
@ -501,6 +520,8 @@ class SettingsActivity : AppCompatActivity() {
private fun cleanupAction() {
currentAction = UsbAction.NONE
expectingReboot = false
rebootTimeoutRunnable?.let { rebootTimeoutHandler.removeCallbacks(it) }
rebootTimeoutRunnable = null
dialog?.dismiss()
dialog = null
progressDialog?.dismiss()

View file

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

View file

@ -27,9 +27,9 @@
<!-- SettingsActivity -->
<string name="title_settings">Instellingen</string>
<string name="msg_settings_intro">Sluit de ESP aan op USB en kies een actie.</string>
<string name="msg_flash_mode_help">Sluit de module aan op de USB-poort.</string>
<string name="button_firmware_flash">Firmware bijwerken</string>
<string name="method_bluetooth">Bluetooth</string>
<string name="method_usb">USB</string>
<string name="button_update_module">Module bijwerken</string>
<string name="usb_access_denied">USB-toegang geweigerd</string>
<string name="key_read_success">Beveiligingssleutel gelezen</string>
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</string>
@ -104,8 +104,7 @@
<string name="theme_applied">Thema: %s</string>
<!-- OTA Updates -->
<string name="button_ota_update">Firmware bijwerken via Bluetooth</string>
<string name="title_ota_update">OTA Firmware Update</string>
<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>

View file

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