feat(android): add broker integration for client OTA detection and triggering
This commit is contained in:
parent
6892103cab
commit
ccf8467c00
3 changed files with 135 additions and 0 deletions
|
|
@ -47,7 +47,13 @@ class BleRelayManager(
|
|||
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 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 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 RECONNECT_DELAY_MS = 2000L
|
||||
|
|
@ -83,6 +89,13 @@ class BleRelayManager(
|
|||
@Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null
|
||||
private var ecdhNotifyBuf: ByteArray = ByteArray(0)
|
||||
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 {
|
||||
fun onOtaNotify(status: Int, seq: Int)
|
||||
|
|
@ -279,6 +292,95 @@ class BleRelayManager(
|
|||
|
||||
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")
|
||||
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
|
||||
|
|
@ -336,11 +438,16 @@ class BleRelayManager(
|
|||
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
|
||||
otaCharacteristic = service.getCharacteristic(OTA_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 versionChar = service.getCharacteristic(VERSION_UUID)
|
||||
if (relayCharacteristic != null && uuidChar != null) {
|
||||
Log.d(TAG, "Reading device UUID characteristic...")
|
||||
gatt.readCharacteristic(uuidChar)
|
||||
if (controlCharacteristic != null) {
|
||||
subscribeToBrokerNotifications()
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Service or characteristics not found")
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
|
||||
|
|
@ -407,6 +514,23 @@ class BleRelayManager(
|
|||
ecdhNotifyBuf = value.copyOf()
|
||||
ecdhDeferred?.complete(true)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,4 +19,5 @@ enum class BleStatus {
|
|||
interface BleCallbacks {
|
||||
fun onStatusChanged(status: BleStatus)
|
||||
fun onFirmwareVersion(espVersion: String, appVersion: String)
|
||||
fun onClientReady(uuid: ByteArray)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue