Meshtastic-Android/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt
2022-09-04 22:52:40 -03:00

447 lines
19 KiB
Kotlin

package com.geeksville.mesh.service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import androidx.core.app.JobIntentService
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.util.exceptionReporter
import java.util.*
import java.util.zip.CRC32
/**
* Some misformatted ESP32s have problems
*/
class DeviceRejectedException : BLEException("Device rejected filesize")
/**
* Move this somewhere as a generic network byte order function
*/
fun toNetworkByteArray(value: Int, formatType: Int): ByteArray {
val len = when (formatType) {
BluetoothGattCharacteristic.FORMAT_UINT8 -> 1
BluetoothGattCharacteristic.FORMAT_UINT32 -> 4
else -> TODO()
}
val mValue = ByteArray(len)
when (formatType) {
/* BluetoothGattCharacteristic.FORMAT_SINT8 -> {
value = intToSignedBits(value, 8)
mValue.get(offset) = (value and 0xFF).toByte()
}
BluetoothGattCharacteristic.FORMAT_UINT8 -> mValue.get(offset) =
(value and 0xFF).toByte()
BluetoothGattCharacteristic.FORMAT_SINT16 -> {
value = intToSignedBits(value, 16)
mValue.get(offset++) = (value and 0xFF).toByte()
mValue.get(offset) = (value shr 8 and 0xFF).toByte()
}
BluetoothGattCharacteristic.FORMAT_UINT16 -> {
mValue.get(offset++) = (value and 0xFF).toByte()
mValue.get(offset) = (value shr 8 and 0xFF).toByte()
}
BluetoothGattCharacteristic.FORMAT_SINT32 -> {
value = intToSignedBits(value, 32)
mValue.get(offset++) = (value and 0xFF).toByte()
mValue.get(offset++) = (value shr 8 and 0xFF).toByte()
mValue.get(offset++) = (value shr 16 and 0xFF).toByte()
mValue.get(offset) = (value shr 24 and 0xFF).toByte()
} */
BluetoothGattCharacteristic.FORMAT_UINT8 ->
mValue[0] = (value and 0xFF).toByte()
BluetoothGattCharacteristic.FORMAT_UINT32 -> {
mValue[0] = (value and 0xFF).toByte()
mValue[1] = (value shr 8 and 0xFF).toByte()
mValue[2] = (value shr 16 and 0xFF).toByte()
mValue[3] = (value shr 24 and 0xFF).toByte()
}
else -> TODO()
}
return mValue
}
data class UpdateFilenames(val appLoad: String?, val littlefs: String?)
/**
* typical flow
*
* startScan
* startUpdate
*
* stopScan
*
* FIXME - if we don't find a device stop our scan
* FIXME - broadcast when we found devices, made progress sending blocks or when the update is complete
* FIXME - make the user decide to start an update on a particular device
*/
class SoftwareUpdateService : JobIntentService(), Logging {
private val bluetoothAdapter: BluetoothAdapter by lazy(LazyThreadSafetyMode.NONE) {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter!!
}
private fun startUpdate(macaddr: String) {
info("starting update to $macaddr")
val device = bluetoothAdapter.getRemoteDevice(macaddr)
val sync =
SafeBluetooth(
this@SoftwareUpdateService,
device
)
sync.connect()
sync.use { _ ->
// we begin by setting our MTU size as high as it can go
sync.requestMtu(512)
sync.discoverServices() // Get our services
val updateFilename = getUpdateFilename(this, sync)
if (updateFilename != null) {
doUpdate(this, sync, updateFilename)
} else
warn("Device is already up-to-date no update needed.")
}
}
override fun onHandleWork(intent: Intent) {
// We have received work to do. The system or framework is already
// holding a wake lock for us at this point, so we can just go.
// Report failures but do not crash the app
exceptionReporter {
debug("Executing work: $intent")
when (intent.action) {
ACTION_START_UPDATE -> {
val addr = intent.getStringExtra(EXTRA_MACADDR)
?: throw Exception("EXTRA_MACADDR not specified")
startUpdate(addr) // FIXME, pass in as an intent arg instead
}
else -> TODO("Unhandled case")
}
}
}
companion object : Logging {
/**
* Unique job ID for this service. Must be the same for all work.
*/
private const val JOB_ID = 1000
fun startUpdateIntent(macAddress: String): Intent {
val i = Intent(ACTION_START_UPDATE)
i.putExtra(EXTRA_MACADDR, macAddress)
return i
}
const val ACTION_START_UPDATE = "$prefix.START_UPDATE"
const val ACTION_UPDATE_PROGRESS = "$prefix.UPDATE_PROGRESS"
const val EXTRA_MACADDR = "macaddr"
private const val SCAN_PERIOD: Long = 10000
private val TAG =
MainActivity::class.java.simpleName // FIXME - use my logging class instead
private val SW_UPDATE_UUID = UUID.fromString("cb0b9a0b-a84c-4c0d-bdbb-442e3144ee30")
private val SW_UPDATE_TOTALSIZE_CHARACTER =
UUID.fromString("e74dd9c0-a301-4a6f-95a1-f0e1dbea8e1e") // write|read total image size, 32 bit, write this first, then read read back to see if it was acceptable (0 mean not accepted)
private val SW_UPDATE_DATA_CHARACTER =
UUID.fromString("e272ebac-d463-4b98-bc84-5cc1a39ee517") // write data, variable sized, recommended 512 bytes, write one for each block of file
private val SW_UPDATE_CRC32_CHARACTER =
UUID.fromString("4826129c-c22a-43a3-b066-ce8f0d5bacc6") // write crc32, write last - writing this will complete the OTA operation, now you can read result
private val SW_UPDATE_RESULT_CHARACTER =
UUID.fromString("5e134862-7411-4424-ac4a-210937432c77") // read|notify result code, readable but will notify when the OTA operation completes
private val SW_UPDATE_REGION_CHARACTER =
UUID.fromString("5e134862-7411-4424-ac4a-210937432c67") // write - used to set the region we are setting (appload vs littlefs)
private val SW_VERSION_CHARACTER = longBLEUUID("2a28")
private val MANUFACTURE_CHARACTER = longBLEUUID("2a29")
private val HW_VERSION_CHARACTER = longBLEUUID("2a27")
const val ProgressSuccess = -1
const val ProgressUpdateFailed = -2
const val ProgressBleException = -3
const val ProgressNotStarted = -4
/**
* % progress through the update
*/
var progress = ProgressNotStarted
/**
* Convenience method for enqueuing work in to this service.
*/
fun enqueueWork(context: Context, work: Intent) {
enqueueWork(
context,
SoftwareUpdateService::class.java,
JOB_ID, work
)
}
/**
* true if we are busy with an update right now
*/
val isUpdating get() = progress >= 0
/**
* Update our progress indication for GUIs
*
* @param isAppload if false, we don't report failure indications (because we consider littlefs non critical for now). But do report to analytics
*/
fun sendProgress(context: Context, p: Int, isAppload: Boolean) {
if (!isAppload && p < 0)
errormsg("Error while writing littlefs $p") // treat errors writing littlefs as non fatal for now (user partition probably missized and most people don't need it)
else
if (progress != p) {
progress = p
val intent = Intent(ACTION_UPDATE_PROGRESS).putExtra(
EXTRA_PROGRESS,
p
)
context.sendBroadcast(intent)
}
}
/** Return true if we thing the firmwarte shoulde be updated
*
* @param swVer the version of the software running on the target
*/
fun shouldUpdate(
context: Context,
deviceVersion: DeviceVersion
): Boolean = try {
val curVer = DeviceVersion(context.getString(R.string.cur_firmware_version))
val minVer =
DeviceVersion("0.7.8") // The oldest device version with a working software update service
((curVer > deviceVersion) && (deviceVersion >= minVer))
} catch (ex: Exception) {
errormsg("Error finding swupdate info", ex)
false // If we fail parsing our update info
}
/** Return a Pair of appload filename, littlefs filename this device needs to use as an update (or null if no update needed)
*/
fun getUpdateFilename(
context: Context,
mfg: String
): UpdateFilenames {
val curVer = context.getString(R.string.cur_firmware_version)
// Check to see if the file exists (some builds might not include update files for size reasons)
val firmwareFiles = context.assets.list("firmware") ?: arrayOf()
val appLoad = "firmware-$mfg-$curVer.bin"
val littlefs = "littlefs-$curVer.bin"
return UpdateFilenames(
if (firmwareFiles.contains(appLoad))
"firmware/$appLoad"
else
null,
if (firmwareFiles.contains(littlefs))
"firmware/$littlefs"
else
null
)
}
/** Return the filename this device needs to use as an update (or null if no update needed)
* No longer used, because we get update info inband from our radio API
*/
fun getUpdateFilename(context: Context, sync: SafeBluetooth): UpdateFilenames? {
val service = sync.gatt!!.services.find { it.uuid == SW_UPDATE_UUID }!!
//val hwVerDesc = service.getCharacteristic(HW_VERSION_CHARACTER)
val mfgDesc = service.getCharacteristic(MANUFACTURE_CHARACTER)
//val swVerDesc = service.getCharacteristic(SW_VERSION_CHARACTER)
// looks like HELTEC
val mfg = sync.readCharacteristic(mfgDesc).getStringValue(0)
return getUpdateFilename(context, mfg)
}
/**
* A public function so that if you have your own SafeBluetooth connection already open
* you can use it for the software update.
*/
fun doUpdate(context: Context, sync: SafeBluetooth, assets: UpdateFilenames) {
// calculate total firmware size (littlefs + appLoad)
var totalFirmwareSize = 0
if (assets.appLoad != null && assets.littlefs != null) {
totalFirmwareSize += context.assets.open(assets.appLoad).available()
totalFirmwareSize += context.assets.open(assets.littlefs).available()
}
// we must attempt littlefs first, because if we update the appload the device will reboot afterwards
try {
assets.littlefs?.let { doUpdate(context, sync, it, FLASH_REGION_LITTLEFS, totalFirmwareSize) }
} catch (_: BLECharacteristicNotFoundException) {
// If we can't update littlefs (because not supported by target), do not fail
errormsg("Ignoring failure to update littlefs on old appload")
} catch (_: DeviceRejectedException) {
// the spi filesystem of this device is malformatted, fail silently because most users don't need the web server
errormsg("Device rejected invalid littlefs partition")
}
assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD, totalFirmwareSize) }
sendProgress(context, ProgressSuccess, true)
}
// writable region codes in the ESP32 update code
private val FLASH_REGION_APPLOAD = 0
private val FLASH_REGION_LITTLEFS = 100
/**
* A public function so that if you have your own SafeBluetooth connection already open
* you can use it for the software update.
*/
private fun doUpdate(
context: Context,
sync: SafeBluetooth,
assetName: String,
flashRegion: Int = FLASH_REGION_APPLOAD,
totalFirmwareSize: Int = 0
) {
val isAppload = flashRegion == FLASH_REGION_APPLOAD
try {
val g = sync.gatt!!
val service = g.services.find { it.uuid == SW_UPDATE_UUID }
?: throw BLEException("Couldn't find update service")
/**
* Get a chracteristic, but in a safe manner because some buggy BLE implementations might return null
*/
fun getCharacteristic(uuid: UUID) =
service.getCharacteristic(uuid)
?: throw BLECharacteristicNotFoundException(uuid)
info("Starting firmware update for $assetName, flash region $flashRegion")
sendProgress(context, 0, isAppload)
val totalSizeDesc = getCharacteristic(SW_UPDATE_TOTALSIZE_CHARACTER)
val dataDesc = getCharacteristic(SW_UPDATE_DATA_CHARACTER)
val crc32Desc = getCharacteristic(SW_UPDATE_CRC32_CHARACTER)
val updateResultDesc = getCharacteristic(SW_UPDATE_RESULT_CHARACTER)
/// Try to set the destination region for programming (littlefs vs appload etc)
/// Old apploads don't have this feature, but we only fail if the user was trying to set a
/// littlefs - otherwise we assume appload.
try {
val updateRegionDesc = getCharacteristic(SW_UPDATE_REGION_CHARACTER)
sync.writeCharacteristic(
updateRegionDesc,
toNetworkByteArray(flashRegion, BluetoothGattCharacteristic.FORMAT_UINT8)
)
} catch (ex: BLECharacteristicNotFoundException) {
errormsg("Can't set flash programming region (old appload?")
if (flashRegion != FLASH_REGION_APPLOAD) {
throw ex
}
warn("Ignoring setting appload flashRegion")
}
context.assets.open(assetName).use { firmwareStream ->
val firmwareCrc = CRC32()
var firmwareNumSent = 0
val firmwareSize = firmwareStream.available()
// Start the update by writing the # of bytes in the image
sync.writeCharacteristic(
totalSizeDesc,
toNetworkByteArray(firmwareSize, BluetoothGattCharacteristic.FORMAT_UINT32)
)
// Our write completed, queue up a readback
val totalSizeReadback = sync.readCharacteristic(totalSizeDesc)
.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT32, 0)
if (totalSizeReadback == 0)
throw DeviceRejectedException()
// Send all the blocks
var oldProgress = -1 // used to limit # of log spam
while (firmwareNumSent < firmwareSize) {
// If we are doing the littlefs partition, we limit progress to a max of maxProgress
// when updating the appload partition, progress from (100 - maxProgress) to 100%
// maxProgress = littlefs% = 100% - appLoad%; (int * 10 + 5) / 10 used for rounding
val maxProgress = ((firmwareSize * 1000 / totalFirmwareSize) + 5) / 10
val minProgress = if (flashRegion != FLASH_REGION_APPLOAD)
0 else (100 - maxProgress)
sendProgress(
context,
minProgress + firmwareNumSent * maxProgress / firmwareSize,
isAppload
)
if (progress != oldProgress) {
debug("sending block ${progress}%")
oldProgress = progress
}
var blockSize = 512 - 3 // Max size MTU excluding framing
if (blockSize > firmwareStream.available())
blockSize = firmwareStream.available()
val buffer = ByteArray(blockSize)
// slightly expensive to keep reallocing this buffer, but whatever
logAssert(firmwareStream.read(buffer) == blockSize)
firmwareCrc.update(buffer)
sync.writeCharacteristic(dataDesc, buffer)
firmwareNumSent += blockSize
}
try {
// We have finished sending all our blocks, so post the CRC so our state machine can advance
val c = firmwareCrc.value
info("Sent all blocks, crc is $c")
sync.writeCharacteristic(
crc32Desc,
toNetworkByteArray(c.toInt(), BluetoothGattCharacteristic.FORMAT_UINT32)
)
// we just read the update result if !0 we have an error
val updateResult =
sync.readCharacteristic(updateResultDesc)
.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0)
if (updateResult != 0) {
sendProgress(context, ProgressUpdateFailed, isAppload)
throw Exception("Device update failed, reason=$updateResult")
}
// Device will now reboot
} catch (ex: BLEException) {
// We might get SyncContinuation timeout on the final write, assume the device simply rebooted to run the new load and we missed it
errormsg("Assuming successful update", ex)
}
}
} catch (ex: BLEException) {
sendProgress(context, ProgressBleException, isAppload)
throw ex // Unexpected BLE exception
}
}
}
}