diff --git a/android/app/src/main/assets/firmware/firmware.bin b/android/app/src/main/assets/firmware/firmware.bin index 4f086b7..9c849f5 100644 Binary files a/android/app/src/main/assets/firmware/firmware.bin and b/android/app/src/main/assets/firmware/firmware.bin differ diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt index 1cc0195..e932aba 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -23,23 +23,27 @@ import java.nio.ByteOrder import java.util.UUID import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec +import kotlin.text.Charsets class BleRelayManager( private val context: Context, private val callbacks: BleCallbacks ) { companion object { + var instance: BleRelayManager? = null private const val TAG = "BleRelayManager" private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9") + 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 const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L private const val MAX_SCAN_RETRIES = 3 private const val HMAC_PKT_SIZE = 37 private const val HMAC_LEN = 32 - private const val BLE_MTU = 256 + private const val BLE_MTU = 512 } private var bluetoothGatt: BluetoothGatt? = null @@ -52,6 +56,24 @@ class BleRelayManager( private var hmacKey: ByteArray? = null private var deviceUuid: ByteArray? = null private var triedAddresses = mutableSetOf() + private var otaCharacteristic: BluetoothGattCharacteristic? = null + private var espVersion: String? = null + private var appVersion: String = "" + private var negotiatedMtu: Int = 23 + + interface OtaNotifyCallback { + fun onOtaNotify(status: Int, seq: Int) + } + + private var otaNotifyCallback: OtaNotifyCallback? = null + + fun setOtaNotifyCallback(callback: OtaNotifyCallback?) { + otaNotifyCallback = callback + } + + init { + instance = this + } private val scanRunnable = Runnable { startScan() } private var scanTimeoutRunnable: Runnable? = null @@ -170,6 +192,26 @@ class BleRelayManager( stopScan() bluetoothGatt?.disconnect() } + + @SuppressLint("MissingPermission") + fun sendOtaData(data: ByteArray): Boolean { + val char = otaCharacteristic ?: return false + val gatt = bluetoothGatt ?: return false + char.value = data + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + return gatt.writeCharacteristic(char) + } + + @SuppressLint("MissingPermission") + fun enableOtaNotifications(): Boolean { + val char = otaCharacteristic ?: return false + val gatt = bluetoothGatt ?: return false + return gatt.setCharacteristicNotification(char, true) + } + + fun hasOtaCharacteristic(): Boolean { + return otaCharacteristic != null + } fun updateKeys() { loadState() } @@ -182,6 +224,14 @@ class BleRelayManager( return bluetoothGatt != null } + fun setAppVersion(version: String) { + appVersion = version + } + + fun getEspVersion(): String? = espVersion + + fun getNegotiatedMtu(): Int = negotiatedMtu + @SuppressLint("MissingPermission") private fun connectToDevice(device: android.bluetooth.BluetoothDevice) { handler.post { callbacks.onStatusChanged(BleStatus.Connecting) } @@ -227,10 +277,13 @@ class BleRelayManager( @SuppressLint("MissingPermission") override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) { Log.d(TAG, "MTU changed to $mtu") + negotiatedMtu = mtu val service = gatt.getService(SERVICE_UUID) if (service != null) { relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) + otaCharacteristic = service.getCharacteristic(OTA_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) @@ -249,14 +302,38 @@ class BleRelayManager( if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) { val storedUuid = deviceUuid if (storedUuid != null && value.contentEquals(storedUuid)) { - Log.d(TAG, "UUID verified, setting up relay") - handler.post { callbacks.onStatusChanged(BleStatus.Connected) } + Log.d(TAG, "UUID verified, reading version...") + val service = gatt.getService(SERVICE_UUID) + val versionChar = service?.getCharacteristic(VERSION_UUID) + if (versionChar != null) { + val ok = gatt.readCharacteristic(versionChar) + if (!ok) { + Log.w(TAG, "readCharacteristic(VERSION) returned false, treating as no version") + handler.post { callbacks.onFirmwareVersion("", appVersion) } + handler.post { callbacks.onStatusChanged(BleStatus.Connected) } + } + } else { + Log.d(TAG, "VERSION characteristic not found on ESP (old firmware)") + handler.post { callbacks.onFirmwareVersion("", appVersion) } + handler.post { callbacks.onStatusChanged(BleStatus.Connected) } + } } else { Log.w(TAG, "UUID mismatch -- disconnecting") triedAddresses.add(gatt.device.address) handler.post { callbacks.onStatusChanged(BleStatus.WrongDevice) } gatt.disconnect() } + } else if (characteristic.uuid == VERSION_UUID) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val version = String(value, Charsets.UTF_8).trim() + espVersion = version + Log.d(TAG, "ESP firmware version: $version, app bundled: $appVersion") + handler.post { callbacks.onFirmwareVersion(version, appVersion) } + } else { + Log.w(TAG, "Version read failed (status=$status), treating as no version") + handler.post { callbacks.onFirmwareVersion("", appVersion) } + } + handler.post { callbacks.onStatusChanged(BleStatus.Connected) } } } @@ -267,6 +344,25 @@ class BleRelayManager( handler.post { callbacks.onStatusChanged(BleStatus.CommError) } } } + + @SuppressLint("MissingPermission") + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + if (characteristic.uuid == OTA_UUID && value.size >= 1) { + val status = value[0].toInt() and 0xFF + val seq = if (value.size >= 5) { + ((value[1].toInt() and 0xFF) shl 24) or + ((value[2].toInt() and 0xFF) shl 16) or + ((value[3].toInt() and 0xFF) shl 8) or + (value[4].toInt() and 0xFF) + } else 0 + Log.d(TAG, "OTA notify: status=$status seq=$seq") + handler.post { otaNotifyCallback?.onOtaNotify(status, seq) } + } + } } private fun loadState() { diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt index 110aac8..ee44abd 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt @@ -18,4 +18,5 @@ enum class BleStatus { interface BleCallbacks { fun onStatusChanged(status: BleStatus) + fun onFirmwareVersion(espVersion: String, appVersion: String) } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt index 5f5fe53..da135a9 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt @@ -10,8 +10,8 @@ import android.os.Handler import android.os.Looper import android.view.MotionEvent import android.widget.Button -import android.widget.ImageButton import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -22,6 +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 = "5" } private lateinit var manager: BleRelayManager @@ -90,13 +91,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks { setContentView(R.layout.activity_main) manager = BleRelayManager(this, this) + manager.setAppVersion(BUNDLED_FIRMWARE_VERSION) statusText = findViewById(R.id.statusText) relay1Button = findViewById(R.id.relay1Button) relay2Button = findViewById(R.id.relay2Button) - val settingsButton = findViewById(R.id.settingsButton) + val settingsContainer = findViewById(R.id.settingsContainer) - settingsButton.setOnClickListener { + settingsContainer.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } setupRelayButton() @@ -217,4 +219,16 @@ class MainActivity : AppCompatActivity(), BleCallbacks { handler.removeCallbacksAndMessages(null) manager.destroy() } + + 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_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() + } + } + } } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt new file mode 100644 index 0000000..eb0fffb --- /dev/null +++ b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package nl.eddydevink.sylda + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +class OtaManager( + private val context: Context, + private val relayManager: BleRelayManager +) { + companion object { + private const val TAG = "OtaManager" + private const val OTA_CMD_START: Byte = 0x10 + private const val OTA_START_TIMEOUT_MS = 10000L + private const val OTA_CHUNK_TIMEOUT_MS = 2000L + private const val OTA_FINAL_TIMEOUT_MS = 10000L + private const val MAX_RETRIES = 3 + private const val OTA_ESP_MAX_CHUNK = 500 + } + + interface OtaCallback { + fun onProgress(percent: Int, message: String) + fun onComplete() + fun onError(message: String) + } + + private var firmwareData: ByteArray? = null + private var chunkDataSize: Int = 248 + + private fun calculateChunkSize(mtu: Int): Int { + return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK) + } + + fun loadFirmware(): Boolean { + return try { + firmwareData = context.assets.open("firmware/firmware.bin").readBytes() + chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu()) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to load firmware", e) + false + } + } + + suspend fun performOta(callback: OtaCallback): Boolean = withContext(Dispatchers.IO) { + val fw = firmwareData ?: run { + withContext(Dispatchers.Main) { callback.onError("Firmware niet geladen") } + return@withContext false + } + + val sha256 = MessageDigest.getInstance("SHA-256").digest(fw) + var chunkAckReceived = false + var chunkNakReceived = false + var finalStatusReceived = false + var finalStatus = -1 + + val notifyCallback = object : BleRelayManager.OtaNotifyCallback { + override fun onOtaNotify(status: Int, seq: Int) { + when (status) { + 0x00 -> chunkAckReceived = true + 0x01 -> chunkNakReceived = true + 0x10 -> { finalStatusReceived = true; finalStatus = 0x10 } + 0x11 -> { finalStatusReceived = true; finalStatus = 0x11 } + } + } + } + relayManager.setOtaNotifyCallback(notifyCallback) + + try { + withContext(Dispatchers.Main) { callback.onProgress(0, "OTA starten...") } + + val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN) + startPacket.put(OTA_CMD_START) + startPacket.putInt(fw.size) + relayManager.sendOtaData(startPacket.array()) + + try { + withTimeout(OTA_START_TIMEOUT_MS) { + while (true) { delay(100); ensureActive() } + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) {} + + val totalChunks = (fw.size + chunkDataSize - 1) / chunkDataSize + var seq = 0 + var offset = 0 + + while (offset < fw.size) { + val remaining = fw.size - offset + val chunkSize = minOf(remaining, chunkDataSize) + + val packet = ByteBuffer.allocate(4 + chunkSize).order(ByteOrder.BIG_ENDIAN) + packet.putInt(seq) + packet.put(fw, offset, chunkSize) + + chunkAckReceived = false + chunkNakReceived = false + var retries = 0 + + while (retries < MAX_RETRIES) { + val writeOk = relayManager.sendOtaData(packet.array()) + if (!writeOk) { + Log.w(TAG, "writeCharacteristic returned false for chunk $seq, retrying") + retries++ + delay(100) + continue + } + Log.d(TAG, "Sent chunk $seq/$totalChunks ($chunkSize bytes) attempt ${retries + 1}") + + try { + withTimeout(OTA_CHUNK_TIMEOUT_MS) { + while (!chunkAckReceived && !chunkNakReceived) { + delay(50) + ensureActive() + } + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + Log.w(TAG, "Timeout waiting for ACK/NAK on chunk $seq") + } + + if (chunkAckReceived) break + retries++ + Log.w(TAG, "Chunk $seq NAK/timeout, retry $retries/$MAX_RETRIES") + } + + if (!chunkAckReceived) { + relayManager.setOtaNotifyCallback(null) + withContext(Dispatchers.Main) { callback.onError("OTA mislukt: chunk $seq geen ACK na $MAX_RETRIES pogingen") } + return@withContext false + } + + val pct = ((offset.toFloat() / fw.size.toFloat()) * 99).toInt().coerceAtMost(99) + withContext(Dispatchers.Main) { + callback.onProgress(pct, "OTA bezig: ${seq + 1}/$totalChunks") + } + + offset += chunkSize + seq++ + } + + withContext(Dispatchers.Main) { callback.onProgress(99, "Hash verifiëren...") } + + finalStatusReceived = false + relayManager.sendOtaData(sha256) + + try { + withTimeout(OTA_FINAL_TIMEOUT_MS) { + while (!finalStatusReceived) { + delay(100) + ensureActive() + } + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + Log.w(TAG, "Timeout waiting for final OTA status") + } + + relayManager.setOtaNotifyCallback(null) + + if (finalStatus == 0x10) { + withContext(Dispatchers.Main) { + callback.onProgress(100, "Herstart...") + callback.onComplete() + } + true + } else { + withContext(Dispatchers.Main) { + callback.onError("OTA mislukt: ESP SHA256 verificatie fout") + } + false + } + } catch (e: Exception) { + relayManager.setOtaNotifyCallback(null) + withContext(Dispatchers.Main) { callback.onError("OTA fout: ${e.message}") } + false + } + } +} diff --git a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt index 6037d5f..10d810d 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt @@ -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 } @@ -74,7 +74,11 @@ class SettingsActivity : AppCompatActivity() { private var progressDialog: AlertDialog? = null private var dialog: AlertDialog? = null private var flasherJob: Job? = null + 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) { @@ -136,8 +140,12 @@ class SettingsActivity : AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.title = getString(R.string.title_settings) - findViewById