feat(android): add broker integration for client OTA detection and triggering

This commit is contained in:
eddy 2026-08-04 22:17:23 +02:00
commit ccf8467c00
3 changed files with 135 additions and 0 deletions

View file

@ -47,7 +47,13 @@ class BleRelayManager(
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db") 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 val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc") private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc")
private val CLIENT_REGISTRY_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dd")
private val CLIENT_CONTROL_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26de")
private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val BROKER_PLAIN_SIZE = 20
private const val BROKER_PKT_SIZE = 4 + 8 + BROKER_PLAIN_SIZE + 16
private const val BROKER_CMD_UPDATE_AVAILABLE: Byte = 0x10
private const val BROKER_CMD_CLIENT_READY: Byte = 0x20
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
@ -83,6 +89,13 @@ class BleRelayManager(
@Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null @Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null
private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhNotifyBuf: ByteArray = ByteArray(0)
private var ecdhCccdPending = false private var ecdhCccdPending = false
private var registryCharacteristic: BluetoothGattCharacteristic? = null
private var controlCharacteristic: BluetoothGattCharacteristic? = null
private var brokerNotifyBuf: ByteArray = ByteArray(0)
private var brokerDeferred: CompletableDeferred<Boolean>? = null
private var clientUuid: ByteArray? = null
private var clientVersion: Int = 0
private var clientStatus: Int = 0
interface OtaNotifyCallback { interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int) fun onOtaNotify(status: Int, seq: Int)
@ -279,6 +292,95 @@ class BleRelayManager(
fun getNegotiatedMtu(): Int = negotiatedMtu fun getNegotiatedMtu(): Int = negotiatedMtu
fun getClientUuid(): ByteArray? = clientUuid
fun getClientVersion(): Int = clientVersion
fun getClientStatus(): Int = clientStatus
@SuppressLint("MissingPermission")
fun sendUpdateAvailable(targetUuid: ByteArray): Boolean {
val gatt = bluetoothGatt ?: return false
val key = aesKey ?: return false
val ctrlChar = controlCharacteristic ?: return false
val plainPayload = ByteArray(BROKER_PLAIN_SIZE)
plainPayload[0] = BROKER_CMD_UPDATE_AVAILABLE
System.arraycopy(targetUuid, 0, plainPayload, 1, targetUuid.size)
val pkt = buildBrokerPacket(plainPayload, key)
ctrlChar.value = pkt
ctrlChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return gatt.writeCharacteristic(ctrlChar)
}
@SuppressLint("MissingPermission")
fun subscribeToBrokerNotifications(): Boolean {
val gatt = bluetoothGatt ?: return false
val ctrlChar = controlCharacteristic ?: return false
if (!gatt.setCharacteristicNotification(ctrlChar, true)) {
Log.e(TAG, "Broker: setCharacteristicNotification returned false")
return false
}
val desc = ctrlChar.getDescriptor(OTA_CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (!gatt.writeDescriptor(desc)) {
Log.w(TAG, "Broker: writeDescriptor for CCCD returned false")
}
}
return true
}
private fun buildBrokerPacket(plainPayload: ByteArray, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val nonceRandom = ByteArray(8)
SecureRandom().nextBytes(nonceRandom)
val nonce = ByteArray(GCM_NONCE_LEN)
System.arraycopy(seqBytes, 0, nonce, 0, 4)
System.arraycopy(nonceRandom, 0, nonce, 4, 8)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE,
SecretKeySpec(key, "AES"),
GCMParameterSpec(GCM_TAG_LEN * 8, nonce))
val cipherOutput = cipher.doFinal(plainPayload)
val packet = ByteArray(BROKER_PKT_SIZE)
System.arraycopy(seqBytes, 0, packet, 0, 4)
System.arraycopy(nonceRandom, 0, packet, 4, 8)
System.arraycopy(cipherOutput, 0, packet, 12, cipherOutput.size)
return packet
}
private fun decryptBrokerPacket(data: ByteArray, key: ByteArray): ByteArray? {
if (data.size != BROKER_PKT_SIZE) return null
val nonce = ByteArray(GCM_NONCE_LEN)
System.arraycopy(data, 0, nonce, 0, 4)
System.arraycopy(data, 4, nonce, 4, 8)
val ciphertext = ByteArray(BROKER_PLAIN_SIZE)
System.arraycopy(data, 12, ciphertext, 0, BROKER_PLAIN_SIZE)
val tag = ByteArray(GCM_TAG_LEN)
System.arraycopy(data, 12 + BROKER_PLAIN_SIZE, tag, 0, GCM_TAG_LEN)
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val gcmSpec = GCMParameterSpec(GCM_TAG_LEN * 8, nonce)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), gcmSpec)
cipher.update(ciphertext)
val result = cipher.doFinal(tag)
result
} catch (e: Exception) {
Log.e(TAG, "Broker: decrypt failed", e)
null
}
}
@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) }
@ -336,11 +438,16 @@ class BleRelayManager(
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID) otaCharacteristic = service.getCharacteristic(OTA_UUID)
ecdhCharacteristic = service.getCharacteristic(ECDH_UUID) ecdhCharacteristic = service.getCharacteristic(ECDH_UUID)
registryCharacteristic = service.getCharacteristic(CLIENT_REGISTRY_UUID)
controlCharacteristic = service.getCharacteristic(CLIENT_CONTROL_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_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)
if (controlCharacteristic != null) {
subscribeToBrokerNotifications()
}
} else { } else {
Log.e(TAG, "Service or characteristics not found") Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) } handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
@ -407,6 +514,23 @@ class BleRelayManager(
ecdhNotifyBuf = value.copyOf() ecdhNotifyBuf = value.copyOf()
ecdhDeferred?.complete(true) ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)") Log.d(TAG, "ECDH notify received (${value.size} bytes)")
} else if (characteristic.uuid == CLIENT_CONTROL_UUID) {
Log.d(TAG, "Broker: control notification received (${value.size} bytes)")
val key = aesKey
if (key != null) {
val plainPayload = decryptBrokerPacket(value, key)
if (plainPayload != null && plainPayload.size >= BROKER_PLAIN_SIZE) {
val cmd = plainPayload[0].toInt() and 0xFF
Log.d(TAG, "Broker: decrypted cmd=0x${cmd.toString(16)}")
if (cmd == BROKER_CMD_CLIENT_READY.toInt()) {
val uuid = plainPayload.copyOfRange(1, 1 + 16)
clientUuid = uuid
clientStatus = 1
Log.d(TAG, "Broker: client ready for OTA, uuid=${uuid.joinToString("") { "%02X".format(it) }}")
handler.post { callbacks.onClientReady(uuid) }
}
}
}
} }
} }

View file

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

View file

@ -226,4 +226,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
} }
} }
} }
override fun onClientReady(uuid: ByteArray) {
runOnUiThread {
if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread
Toast.makeText(this, "Client ready for OTA update", Toast.LENGTH_SHORT).show()
val intent = Intent(this, SettingsActivity::class.java)
intent.putExtra("client_ota_uuid", uuid)
settingsLauncher.launch(intent)
}
}
} }