Combined flash + key exchange flow with version check and USB permission persistence

- Firmware: add VERSION? command returning BUILD_NR for version check
- Android: flash button checks firmware version first, skips flash if current
- After flash: auto key exchange via reboot detection (expectingReboot flag)
- device_filter.xml + intent-filter: USB permission 'always allow' checkbox
- singleTop + onNewIntent prevents unwanted SettingsActivity launch on USB connect
- platformio.ini: upload_port updated to /dev/ttyACM1
This commit is contained in:
eddy 2026-07-29 22:56:40 +02:00
commit d6d6f6674a
7 changed files with 87 additions and 14 deletions

View file

@ -30,6 +30,15 @@
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:label="Instellingen" android:label="Instellingen"
android:parentActivityName=".MainActivity"/> android:parentActivityName=".MainActivity"
android:launchMode="singleTop"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
</application> </application>
</manifest> </manifest>

View file

@ -60,11 +60,17 @@ class SettingsActivity : AppCompatActivity() {
private const val UUID_HEX_LEN = 32 private const val UUID_HEX_LEN = 32
private const val UUID_BYTE_COUNT = 16 private const val UUID_BYTE_COUNT = 16
private const val HMAC_BYTE_COUNT = 32 private const val HMAC_BYTE_COUNT = 32
private const val VERSION_TIMEOUT_MS = 3000L
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 enum class UsbAction { NONE, EXCHANGE_KEY, FLASH } private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
private var currentAction = UsbAction.NONE private var currentAction = UsbAction.NONE
private var expectingReboot = false
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
@ -109,6 +115,7 @@ class SettingsActivity : AppCompatActivity() {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) { if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) { if (device?.vendorId == ESP_VENDOR_ID) {
if (expectingReboot) return
if (dialog != null && currentAction == UsbAction.NONE) { if (dialog != null && currentAction == UsbAction.NONE) {
dialog?.dismiss() dialog?.dismiss()
dialog = null dialog = null
@ -147,6 +154,13 @@ class SettingsActivity : AppCompatActivity() {
setupLabelEditTexts() setupLabelEditTexts()
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction == UsbAction.NONE) {
finish()
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
@ -319,6 +333,26 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun doFlashing(usbManager: UsbManager, device: UsbDevice) { private fun doFlashing(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_checking_version), STYLE_SPINNER)
lifecycleScope.launch {
val version = readEspVersion(usbManager, device)
if (version == EXPECTED_FIRMWARE_VERSION && !expectingReboot) {
Log.d(TAG, "Firmware version $version matches, skipping flash")
progressDialog?.dismiss()
progressDialog = null
currentAction = UsbAction.EXCHANGE_KEY
doKeyExchange(usbManager, device)
return@launch
}
Log.d(TAG, "Firmware version ${version ?: "none"}, flashing")
progressDialog?.dismiss()
progressDialog = null
doFlashInner(usbManager, device)
}
}
private fun doFlashInner(usbManager: UsbManager, device: UsbDevice) {
dialog?.dismiss() dialog?.dismiss()
dialog = null dialog = null
showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_initializing), STYLE_HORIZONTAL) showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_initializing), STYLE_HORIZONTAL)
@ -337,19 +371,14 @@ class SettingsActivity : AppCompatActivity() {
try { try {
flasher.flashDevice(usbManager, device, progressCallback) flasher.flashDevice(usbManager, device, progressCallback)
if (isFinishing || isDestroyed) return@launch if (isFinishing || isDestroyed) return@launch
currentAction = UsbAction.NONE expectingReboot = true
currentAction = UsbAction.EXCHANGE_KEY
progressDialog?.dismiss() progressDialog?.dismiss()
progressDialog = null progressDialog = null
dialog = AlertDialog.Builder(this@SettingsActivity) showWaitingDialog(
.setTitle(getString(R.string.title_firmware_flash)) getString(R.string.title_firmware_flash),
.setMessage(getString(R.string.msg_flash_complete)) getString(R.string.msg_env_waiting_reboot)
.setPositiveButton(getString(R.string.button_close)) { d, _ -> )
d.dismiss()
dialog = null
}
.setCancelable(true)
.setOnCancelListener { dialog = null }
.show()
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
cleanupAction() cleanupAction()
throw e throw e
@ -412,6 +441,7 @@ class SettingsActivity : AppCompatActivity() {
private fun cleanupAction() { private fun cleanupAction() {
currentAction = UsbAction.NONE currentAction = UsbAction.NONE
expectingReboot = false
dialog?.dismiss() dialog?.dismiss()
dialog = null dialog = null
progressDialog?.dismiss() progressDialog?.dismiss()
@ -440,6 +470,30 @@ class SettingsActivity : AppCompatActivity() {
data class Error(val resId: Int, val arg: String? = null) : KeyResult() data class Error(val resId: Int, val arg: String? = null) : KeyResult()
} }
private fun readEspVersion(usbManager: UsbManager, device: UsbDevice): String? {
val intf = UsbUtils.findCdcDataInterface(device) ?: return null
val connection = usbManager.openDevice(device) ?: return null
if (!connection.claimInterface(intf, true)) { connection.close(); return null }
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return null }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return null }
return try {
setupCdcControl(connection, device)
Thread.sleep(KEY_SETTLE_MS)
val cmd = "VERSION?\n".toByteArray(StandardCharsets.US_ASCII)
connection.bulkTransfer(epOut, cmd, cmd.size, USB_BULK_TIMEOUT_MS)
Thread.sleep(RESPONSE_WAIT_MS)
val response = readResponse(connection, epIn, VERSION_TIMEOUT_MS)
val versionLine = response.lines().firstOrNull { it.startsWith("BUILD_NR:") }
versionLine?.removePrefix("BUILD_NR:")?.trim()
} finally {
connection.close()
}
}
private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice, appUuid: String?, appKey: String?): KeyResult? { private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice, appUuid: String?, appKey: String?): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device) val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error(R.string.err_no_cdc) ?: return KeyResult.Error(R.string.err_no_cdc)

View file

@ -57,7 +57,9 @@
<string name="msg_reading_key">Sleutel uitlezen…</string> <string name="msg_reading_key">Sleutel uitlezen…</string>
<string name="msg_flash_download_mode">Sluit de ESP aan via USB om te flashen.</string> <string name="msg_flash_download_mode">Sluit de ESP aan via USB om te flashen.</string>
<string name="msg_initializing">Initialiseren…</string> <string name="msg_initializing">Initialiseren…</string>
<string name="msg_flash_complete">Firmware geflashed!\n\nDe ESP herstart automatisch. Sleutels blijven behouden in NVS.</string> <string name="msg_checking_version">Firmware versie controleren…</string>
<string name="msg_env_waiting_reboot">Wachten op ESP herstart…</string>
<string name="msg_flash_complete">Firmware geflashed!\n\nSleutels worden nu uitgelezen…</string>
<string name="button_close">Sluiten</string> <string name="button_close">Sluiten</string>
<string name="button_cancel">Annuleren</string> <string name="button_cancel">Annuleren</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<usb-device vendor-id="12346" product-id="4097" />
</resources>

View file

@ -13,5 +13,5 @@ build_flags =
-DCONFIG_BT_NIMBLE_ENABLED -DCONFIG_BT_NIMBLE_ENABLED
lib_deps = lib_deps =
h2zero/NimBLE-Arduino@^2 h2zero/NimBLE-Arduino@^2
upload_port = /dev/ttyACM0 upload_port = /dev/ttyACM1
upload_speed = 115200 upload_speed = 115200

View file

@ -22,6 +22,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 "1"
NimBLECharacteristic *pCharacteristic; NimBLECharacteristic *pCharacteristic;
bool deviceConnected = false; bool deviceConnected = false;
@ -250,6 +251,9 @@ void loop() {
} else { } else {
Serial.println("NO_KEY"); Serial.println("NO_KEY");
} }
} else if (serialBuf == "VERSION?") {
Serial.print("BUILD_NR:");
Serial.println(FIRMWARE_VERSION);
} else if (serialBuf.startsWith("SET_KEY:")) { } else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8); String hex = serialBuf.substring(8);
hex.toUpperCase(); hex.toUpperCase();