diff --git a/.gitignore b/.gitignore index 2cf8684..9338082 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Generated from version.txt +src/version.h +android/app/src/main/java/nl/eddydevink/sylda/SylDaVersions.kt + # PlatformIO .pio/ .pioenvs/ @@ -43,4 +47,18 @@ codereview.md DC3-24V*.md DC3-24V*.pdf esplibalternatief.md +docs/handleiding.md release/ +releases/ + +# Test/debug firmware files +src/blink.cpp +src/c1.cpp +src/get_mac.cpp +src/min_cli.cpp +src/min_srv.cpp +src/s1.cpp +src/test_*.cpp + +# Python cache +scripts/__pycache__/ diff --git a/android/app/build.gradle b/android/app/build.gradle index 4331f86..f288ffc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,9 +8,6 @@ def hasSigningConfig = signingPropsFile.exists() android { namespace 'nl.eddydevink.sylda' - // compileSdk/targetSdk stays on 34 (Android 14) by deliberate choice. The target audience - // consists of physically disabled users who are very reluctant to update their phones. Staying - // on API 34 maximises device compatibility. Re-evaluate when the user base has migrated. compileSdk 34 if (hasSigningConfig) { @@ -59,6 +56,31 @@ android { } } +task generateSylDaVersions { + def versionFile = rootProject.file('../version.txt') + def outputFile = file('src/main/java/nl/eddydevink/sylda/SylDaVersions.kt') + + inputs.file(versionFile) + outputs.file(outputFile) + + doLast { + def versions = [:] + versionFile.eachLine { line -> + def parts = line.trim().split('=') + if (parts.length == 2) versions[parts[0].trim()] = parts[1].trim() + } + outputFile.text = """// Auto-generated from version.txt — DO NOT EDIT +package nl.eddydevink.sylda + +object SylDaVersions { + const val SERVER = "${versions.server}" + const val CLIENT = "${versions.client}" +} +""" + } +} +preBuild.dependsOn generateSylDaVersions + dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' diff --git a/android/app/src/main/assets/firmware/boot_app0.bin b/android/app/src/main/assets/firmware/client/boot_app0.bin similarity index 100% rename from android/app/src/main/assets/firmware/boot_app0.bin rename to android/app/src/main/assets/firmware/client/boot_app0.bin diff --git a/android/app/src/main/assets/firmware/bootloader.bin b/android/app/src/main/assets/firmware/client/bootloader.bin similarity index 100% rename from android/app/src/main/assets/firmware/bootloader.bin rename to android/app/src/main/assets/firmware/client/bootloader.bin diff --git a/android/app/src/main/assets/firmware/client/firmware.bin b/android/app/src/main/assets/firmware/client/firmware.bin new file mode 100644 index 0000000..37c5422 Binary files /dev/null and b/android/app/src/main/assets/firmware/client/firmware.bin differ diff --git a/android/app/src/main/assets/firmware/partitions.bin b/android/app/src/main/assets/firmware/client/partitions.bin similarity index 100% rename from android/app/src/main/assets/firmware/partitions.bin rename to android/app/src/main/assets/firmware/client/partitions.bin diff --git a/android/app/src/main/assets/firmware/firmware.bin b/android/app/src/main/assets/firmware/firmware.bin deleted file mode 100644 index 142bde6..0000000 Binary files a/android/app/src/main/assets/firmware/firmware.bin and /dev/null differ diff --git a/android/app/src/main/assets/firmware/server/boot_app0.bin b/android/app/src/main/assets/firmware/server/boot_app0.bin new file mode 100644 index 0000000..13562ca Binary files /dev/null and b/android/app/src/main/assets/firmware/server/boot_app0.bin differ diff --git a/android/app/src/main/assets/firmware/server/bootloader.bin b/android/app/src/main/assets/firmware/server/bootloader.bin new file mode 100644 index 0000000..204061a Binary files /dev/null and b/android/app/src/main/assets/firmware/server/bootloader.bin differ diff --git a/android/app/src/main/assets/firmware/server/firmware.bin b/android/app/src/main/assets/firmware/server/firmware.bin new file mode 100644 index 0000000..3bfedd2 Binary files /dev/null and b/android/app/src/main/assets/firmware/server/firmware.bin differ diff --git a/android/app/src/main/assets/firmware/server/partitions.bin b/android/app/src/main/assets/firmware/server/partitions.bin new file mode 100644 index 0000000..951265f Binary files /dev/null and b/android/app/src/main/assets/firmware/server/partitions.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..29ceeae 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -6,6 +6,7 @@ import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter @@ -37,6 +38,7 @@ class BleRelayManager( 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 val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L @@ -206,7 +208,18 @@ class BleRelayManager( fun enableOtaNotifications(): Boolean { val char = otaCharacteristic ?: return false val gatt = bluetoothGatt ?: return false - return gatt.setCharacteristicNotification(char, true) + if (!gatt.setCharacteristicNotification(char, true)) { + Log.e(TAG, "setCharacteristicNotification returned false") + return false + } + val desc = char.getDescriptor(OTA_CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + if (!gatt.writeDescriptor(desc)) { + Log.w(TAG, "writeDescriptor for CCCD returned false") + } + } + return true } fun hasOtaCharacteristic(): Boolean { @@ -259,6 +272,7 @@ class BleRelayManager( bluetoothGatt = null relayCharacteristic = null seq = 1 + triedAddresses.clear() handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS) } } @@ -369,7 +383,8 @@ class BleRelayManager( val prefs = PrefsHelper.getPrefs(context) val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) hmacKey = hexKey?.let { hexStringToBytes(it) } - val hexUuid = prefs.getString(PrefsHelper.PREF_UUID, null) + val hexUuid = PrefsHelper.getServerUuid(context) + ?: prefs.getString(PrefsHelper.PREF_UUID, null) deviceUuid = hexUuid?.let { hexStringToBytes(it) } seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) Log.d(TAG, "HMAC key loaded: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq") diff --git a/android/app/src/main/java/nl/eddydevink/sylda/EspFlasher.kt b/android/app/src/main/java/nl/eddydevink/sylda/EspFlasher.kt index 7a1b75d..d525667 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/EspFlasher.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/EspFlasher.kt @@ -28,6 +28,11 @@ class EspFlasher(private val context: Context) { fun onProgress(percent: Int, message: String) } + private fun assetPath(baseName: String): String { + val prefix = if (PrefsHelper.getDeviceMode(context) == "client") "client/" else "server/" + return "firmware/$prefix$baseName" + } + suspend fun flashDevice(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) { try { withContext(kotlinx.coroutines.Dispatchers.IO) { @@ -77,10 +82,10 @@ class EspFlasher(private val context: Context) { coroutineContext.ensureActive() val assets = context.assets - val bootloader = assets.open("firmware/bootloader.bin").readBytes() - val partitions = assets.open("firmware/partitions.bin").readBytes() - val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes() - val firmware = assets.open("firmware/firmware.bin").readBytes() + val bootloader = assets.open(assetPath("bootloader.bin")).readBytes() + val partitions = assets.open(assetPath("partitions.bin")).readBytes() + val bootApp0 = assets.open(assetPath("boot_app0.bin")).readBytes() + val firmware = assets.open(assetPath("firmware.bin")).readBytes() val steps = listOf( FlashStep(R.string.flash_step_bootloader, 0x0, bootloader, 30, 10, false), 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 87cb207..1e8f5bc 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt @@ -23,7 +23,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 = "6" } private lateinit var manager: BleRelayManager @@ -41,8 +40,9 @@ class MainActivity : AppCompatActivity(), BleCallbacks { statusText.text = getString(R.string.status_connected) setupRelayButton() setupRelay2Button() - relay1Button.isEnabled = true - relay2Button.isEnabled = true + val isServer = PrefsHelper.getDeviceMode(this@MainActivity) == "server" + relay1Button.isEnabled = isServer + relay2Button.isEnabled = isServer } BleStatus.Disconnected -> { statusText.text = getString(R.string.status_connecting) @@ -92,7 +92,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks { setContentView(R.layout.activity_main) manager = BleRelayManager(this, this) - manager.setAppVersion(BUNDLED_FIRMWARE_VERSION) + manager.setAppVersion(SylDaVersions.SERVER) statusText = findViewById(R.id.statusText) relay1Button = findViewById(R.id.relay1Button) @@ -107,6 +107,9 @@ class MainActivity : AppCompatActivity(), BleCallbacks { } setupRelayButton() setupRelay2Button() + val isServer = PrefsHelper.getDeviceMode(this) == "server" + relay1Button.isEnabled = isServer + relay2Button.isEnabled = isServer } @Deprecated("Deprecated in Java") @@ -227,11 +230,9 @@ class MainActivity : AppCompatActivity(), BleCallbacks { 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) { + if (espVersion.isNotEmpty() && 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 index eb0fffb..993ad07 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/OtaManager.kt @@ -39,13 +39,14 @@ class OtaManager( return minOf(maxOf(mtu - 3 - 4, 20), OTA_ESP_MAX_CHUNK) } - fun loadFirmware(): Boolean { + fun loadFirmware(assetPath: String = "firmware/firmware.bin"): Boolean { return try { - firmwareData = context.assets.open("firmware/firmware.bin").readBytes() + firmwareData = context.assets.open(assetPath).readBytes() chunkDataSize = calculateChunkSize(relayManager.getNegotiatedMtu()) + Log.d(TAG, "Loaded firmware from $assetPath: ${firmwareData!!.size} bytes, chunkDataSize=$chunkDataSize") true } catch (e: Exception) { - Log.e(TAG, "Failed to load firmware", e) + Log.e(TAG, "Failed to load firmware from $assetPath", e) false } } @@ -80,7 +81,21 @@ class OtaManager( val startPacket = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN) startPacket.put(OTA_CMD_START) startPacket.putInt(fw.size) - relayManager.sendOtaData(startPacket.array()) + var startSent = false + for (attempt in 1..MAX_RETRIES) { + if (relayManager.sendOtaData(startPacket.array())) { + startSent = true + Log.d(TAG, "OTA start packet sent on attempt $attempt") + break + } + Log.w(TAG, "OTA start packet failed attempt $attempt, retrying...") + delay(500) + } + if (!startSent) { + relayManager.setOtaNotifyCallback(null) + withContext(Dispatchers.Main) { callback.onError("OTA mislukt: start packet niet verzonden") } + return@withContext false + } try { withTimeout(OTA_START_TIMEOUT_MS) { diff --git a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt index 5b123ac..9ea18ca 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt @@ -15,8 +15,20 @@ object PrefsHelper { const val PREF_THEME = "theme" const val PREF_LABEL1 = "label_relay1" const val PREF_LABEL2 = "label_relay2" + const val KEY_DEVICE_MODE = "deviceMode" + const val KEY_SERVER_UUID = "serverUuid" + const val KEY_CLIENT_UUID = "clientUuid" const val SEQ_MAX = 1_000_000 + fun getDeviceMode(context: Context): String = getPrefs(context).getString(KEY_DEVICE_MODE, "server") ?: "server" + fun setDeviceMode(context: Context, mode: String) = getPrefs(context).edit().putString(KEY_DEVICE_MODE, mode).apply() + fun getTransportMode(context: Context): String = getPrefs(context).getString("transportMode", "ble") ?: "ble" + fun setTransportMode(context: Context, mode: String) = getPrefs(context).edit().putString("transportMode", mode).apply() + fun getServerUuid(context: Context): String? = getPrefs(context).getString(KEY_SERVER_UUID, null) + fun setServerUuid(context: Context, uuid: String) = getPrefs(context).edit().putString(KEY_SERVER_UUID, uuid).apply() + fun getClientUuid(context: Context): String? = getPrefs(context).getString(KEY_CLIENT_UUID, null) + fun setClientUuid(context: Context, uuid: String) = getPrefs(context).edit().putString(KEY_CLIENT_UUID, uuid).apply() + fun applyTheme(context: Context) { val prefs = getPrefs(context) val mode = when (prefs.getString(PREF_THEME, "system")) { 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 59ea230..58496f6 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt @@ -20,7 +20,6 @@ import android.util.Log import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Button -import android.widget.CheckBox import android.widget.EditText import android.widget.ProgressBar import android.widget.RadioGroup @@ -32,6 +31,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.nio.charset.StandardCharsets import java.security.SecureRandom @@ -64,7 +64,6 @@ 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 = "6" } private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH } @@ -131,6 +130,10 @@ class SettingsActivity : AppCompatActivity() { } } + private fun getExpectedFirmwareVersion(): String { + return if (PrefsHelper.getDeviceMode(this) == "client") SylDaVersions.CLIENT else SylDaVersions.SERVER + } + override fun onCreate(savedInstanceState: Bundle?) { lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" PrefsHelper.applyTheme(this) @@ -140,8 +143,29 @@ class SettingsActivity : AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.title = getString(R.string.title_settings) + val radioDeviceMode = findViewById(R.id.radioDeviceMode) + val radioTransport = findViewById(R.id.radioTransport) + + radioDeviceMode.check( + if (PrefsHelper.getDeviceMode(this) == "client") R.id.radioClient else R.id.radioServer + ) + radioTransport.check( + if (PrefsHelper.getTransportMode(this) == "usb") R.id.radioUsb else R.id.radioBle + ) + + radioDeviceMode.setOnCheckedChangeListener { _, checkedId -> + val mode = if (checkedId == R.id.radioClient) "client" else "server" + PrefsHelper.setDeviceMode(this, mode) + } + + radioTransport.setOnCheckedChangeListener { _, checkedId -> + val mode = if (checkedId == R.id.radioUsb) "usb" else "ble" + PrefsHelper.setTransportMode(this, mode) + } + findViewById