diff --git a/android/app/src/main/assets/firmware/firmware.bin b/android/app/src/main/assets/firmware/firmware.bin index 9c849f5..4f086b7 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 e932aba..1cc0195 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -23,27 +23,23 @@ 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 = 512 + private const val BLE_MTU = 256 } private var bluetoothGatt: BluetoothGatt? = null @@ -56,24 +52,6 @@ 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 @@ -192,26 +170,6 @@ 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() } @@ -224,14 +182,6 @@ 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) } @@ -277,13 +227,10 @@ 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) @@ -302,38 +249,14 @@ 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, 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) } - } + Log.d(TAG, "UUID verified, setting up relay") + 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) } } } @@ -344,25 +267,6 @@ 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 ee44abd..110aac8 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt @@ -18,5 +18,4 @@ 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 da135a9..5f5fe53 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,7 +22,6 @@ 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 @@ -91,14 +90,13 @@ 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 settingsContainer = findViewById(R.id.settingsContainer) + val settingsButton = findViewById(R.id.settingsButton) - settingsContainer.setOnClickListener { + settingsButton.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } setupRelayButton() @@ -219,16 +217,4 @@ 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 deleted file mode 100644 index eb0fffb..0000000 --- a/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt +++ /dev/null @@ -1,184 +0,0 @@ -// 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 10d810d..6037d5f 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 = "5" + private const val EXPECTED_FIRMWARE_VERSION = "1" } private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH } @@ -74,11 +74,7 @@ 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) { @@ -140,12 +136,8 @@ class SettingsActivity : AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.title = getString(R.string.title_settings) - findViewById