Compare commits

..
Author SHA1 Message Date
eddy
e0b2750c2e fix: critical security issues from code review
All checks were successful
CI / build (pull_request) Successful in 1m10s
- Thread safety: Add @Synchronized methods for ECDH buffer access
- Crypto: Use X509EncodedKeySpec instead of hard-coded EC curve params
- Sensitive data: Make aes_key/shared_secret private in CryptoECDH
- Credential storage: Migrate PSK/UUID to EncryptedSharedPreferences
2026-08-04 20:56:52 +02:00
eddy
ec310c1bcb Merge pull request 'fix: auto-detect upload port fails with active log output' (#24) from fix/issue-16-auto-detect-port into main
All checks were successful
CI / build (push) Successful in 58s
2026-08-04 19:18:35 +02:00
eddy
7b59d36bd7 fix: cast CONFIG_LOG_DEFAULT_LEVEL to esp_log_level_t
All checks were successful
CI / build (pull_request) Successful in 1m3s
2026-08-04 19:04:37 +02:00
eddy
3c6b378b9b fix(client): add serial mute during ID? response 2026-08-04 19:03:54 +02:00
eddy
ae5028503f fix(server): add serial mute during ID? response 2026-08-04 19:03:21 +02:00
eddy
99fd6b21d4 fix(script): filter ID response in query_id() 2026-08-04 19:02:51 +02:00
eddy
2030b42ba2 docs: add implementation plan for issue #16 2026-08-04 19:01:57 +02:00
eddy
30fc7532ae docs: add design spec for issue #16 auto-detect port fix 2026-08-04 18:59:14 +02:00
09b6ff6823 Merge pull request 'fix: remove outdated ble_client.py, add test_ota_notify.py to tools' (#22) from fix/cleanup-outdated-scripts into main
All checks were successful
CI / build (push) Successful in 1m16s
2026-08-04 18:48:54 +02:00
31f1def3da Merge pull request 'fix: add SPDX license headers to firmware files' (#21) from fix/minor-firmware-issues into main
All checks were successful
CI / build (push) Successful in 1m16s
2026-08-04 18:46:47 +02:00
7829528e89 Merge pull request 'fix: critical issues from code review' (#20) from fix/code-review-critical-issues into main
All checks were successful
CI / build (push) Successful in 1m27s
2026-08-04 18:44:55 +02:00
91739002ef Merge pull request 'fix: use Activity Result API instead of deprecated methods' (#23) from fix/minor-android-issues into main
All checks were successful
CI / build (push) Successful in 1m22s
2026-08-04 18:41:26 +02:00
eddy
ce1a53eee6 fix: use Activity Result API instead of deprecated methods
All checks were successful
CI / build (pull_request) Successful in 1m25s
- Replace onActivityResult with registerForActivityResult
- Replace onRequestPermissionsResult with permissionLauncher
- Remove deprecated @Suppress annotations
2026-08-04 18:34:26 +02:00
eddy
c36ca10edd fix: remove outdated ble_client.py, add test_ota_notify.py to tools
All checks were successful
CI / build (pull_request) Successful in 1m22s
- Remove scripts/ble_client.py (uses old UUIDs, incompatible)
- Move test_ota_notify.py to tools/ directory
- Make serial port configurable via SYLDA_CLIENT_PORT env var
2026-08-04 18:33:35 +02:00
eddy
8aeb609b2e fix: add SPDX license headers to firmware files
All checks were successful
CI / build (pull_request) Successful in 1m12s
2026-08-04 18:32:15 +02:00
12 changed files with 621 additions and 166 deletions

View file

@ -22,8 +22,10 @@ import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.KeyFactory
import java.security.SecureRandom import java.security.SecureRandom
import java.security.spec.ECGenParameterSpec import java.security.spec.ECGenParameterSpec
import java.security.spec.X509EncodedKeySpec
import java.security.interfaces.ECPublicKey import java.security.interfaces.ECPublicKey
import java.util.UUID import java.util.UUID
import javax.crypto.Cipher import javax.crypto.Cipher
@ -61,6 +63,25 @@ class BleRelayManager(
private const val ECDH_PKT_SIZE = 97 private const val ECDH_PKT_SIZE = 97
private const val BLE_MTU = 512 private const val BLE_MTU = 512
private const val ECDH_TIMEOUT_MS = 5000L private const val ECDH_TIMEOUT_MS = 5000L
private val SECP256R1_SPKI_HEADER = byteArrayOf(
0x30.toByte(), 0x59.toByte(), 0x30.toByte(), 0x13.toByte(),
0x06.toByte(), 0x07.toByte(), 0x2A.toByte(), 0x86.toByte(),
0x48.toByte(), 0xCE.toByte(), 0x3D.toByte(), 0x02.toByte(),
0x01.toByte(), 0x06.toByte(), 0x08.toByte(), 0x2A.toByte(),
0x86.toByte(), 0x48.toByte(), 0xCE.toByte(), 0x3D.toByte(),
0x03.toByte(), 0x01.toByte(), 0x07.toByte(), 0x03.toByte(),
0x42.toByte(), 0x00.toByte()
)
private fun decodeEcPublicKey(uncompressedKey: ByteArray): ECPublicKey {
require(uncompressedKey.size == ECDH_PUBKEY_LEN && uncompressedKey[0] == 0x04.toByte()) {
"Invalid uncompressed EC key format"
}
val spkiEncoded = SECP256R1_SPKI_HEADER + uncompressedKey
return KeyFactory.getInstance("EC")
.generatePublic(X509EncodedKeySpec(spkiEncoded)) as ECPublicKey
}
} }
private var bluetoothGatt: BluetoothGatt? = null private var bluetoothGatt: BluetoothGatt? = null
@ -84,6 +105,16 @@ class BleRelayManager(
private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhNotifyBuf: ByteArray = ByteArray(0)
private var ecdhCccdPending = false private var ecdhCccdPending = false
@Synchronized
private fun setEcdhNotifyBuf(value: ByteArray) {
ecdhNotifyBuf = value.copyOf()
}
@Synchronized
private fun getEcdhNotifyBuf(): ByteArray {
return ecdhNotifyBuf.copyOf()
}
interface OtaNotifyCallback { interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int) fun onOtaNotify(status: Int, seq: Int)
} }
@ -404,7 +435,7 @@ class BleRelayManager(
Log.d(TAG, "OTA notify: status=$statusName seq=$seqVal raw=${value.joinToString("") { "%02X".format(it) }}") Log.d(TAG, "OTA notify: status=$statusName seq=$seqVal raw=${value.joinToString("") { "%02X".format(it) }}")
handler.post { otaNotifyCallback?.onOtaNotify(statusVal, seqVal) } handler.post { otaNotifyCallback?.onOtaNotify(statusVal, seqVal) }
} else if (characteristic.uuid == ECDH_UUID) { } else if (characteristic.uuid == ECDH_UUID) {
ecdhNotifyBuf = value.copyOf() setEcdhNotifyBuf(value)
ecdhDeferred?.complete(true) ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)") Log.d(TAG, "ECDH notify received (${value.size} bytes)")
} }
@ -506,7 +537,7 @@ class BleRelayManager(
return@Thread return@Thread
} }
val resp = ecdhNotifyBuf val resp = getEcdhNotifyBuf()
if (resp.size != ECDH_PKT_SIZE) { if (resp.size != ECDH_PKT_SIZE) {
Log.w(TAG, "ECDH: bad response size ${resp.size}") Log.w(TAG, "ECDH: bad response size ${resp.size}")
handler.post { handler.post {
@ -530,28 +561,7 @@ class BleRelayManager(
val ka = KeyAgreement.getInstance("ECDH") val ka = KeyAgreement.getInstance("ECDH")
ka.init(keyPair.private) ka.init(keyPair.private)
val spec = java.security.spec.ECPublicKeySpec( val serverKey = decodeEcPublicKey(serverPub)
java.security.spec.ECPoint(
java.math.BigInteger(1, serverPub.copyOfRange(1, 33)),
java.math.BigInteger(1, serverPub.copyOfRange(33, 65))
),
java.security.spec.ECParameterSpec(
java.security.spec.EllipticCurve(
java.security.spec.ECFieldFp(
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", 16)
),
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", 16),
java.math.BigInteger("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", 16)
),
java.security.spec.ECPoint(
java.math.BigInteger("6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296", 16),
java.math.BigInteger("4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5", 16)
),
java.math.BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16),
1
)
)
val serverKey = java.security.KeyFactory.getInstance("EC").generatePublic(spec)
ka.doPhase(serverKey, true) ka.doPhase(serverKey, true)
val shared = ka.generateSecret() val shared = ka.generateSecret()
@ -606,13 +616,13 @@ class BleRelayManager(
} }
private fun loadState() { private fun loadState() {
val prefs = PrefsHelper.getPrefs(context) PrefsHelper.migrateCredentialsIfNeeded(context)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) val hexKey = PrefsHelper.getPsk(context)
psk = hexKey?.let { hexStringToBytes(it) } psk = hexKey?.let { hexStringToBytes(it) }
val hexUuid = PrefsHelper.getServerUuid(context) val hexUuid = PrefsHelper.getServerUuid(context)
?: prefs.getString(PrefsHelper.PREF_UUID, null) ?: PrefsHelper.getDeviceCredentialUuid(context)
deviceUuid = hexUuid?.let { hexStringToBytes(it) } deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) seq = PrefsHelper.getPrefs(context).getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
Log.d(TAG, "PSK loaded: ${psk?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq") Log.d(TAG, "PSK loaded: ${psk?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq")
} }

View file

@ -2,7 +2,6 @@
package nl.eddydevink.sylda package nl.eddydevink.sylda
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -13,14 +12,13 @@ import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity(), BleCallbacks { class MainActivity : AppCompatActivity(), BleCallbacks {
companion object { companion object {
private const val REQUEST_SETTINGS = 1
private const val POST_SETTINGS_RESCAN_MS = 500L private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L private const val ON_RESUME_SCAN_DELAY_MS = 300L
} }
@ -34,6 +32,25 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
private var lastTheme: String? = null private var lastTheme: String? = null
private var triedPermissions = false private var triedPermissions = false
private val settingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == SettingsActivity.RESULT_SCAN) {
triedPermissions = false
manager.disconnect()
manager.resetTriedAddresses()
manager.updateKeys()
handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
}
}
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
if (grants.values.all { it }) {
manager.startScan()
} else {
triedPermissions = false
onStatusChanged(BleStatus.PermissionsDenied)
}
}
override fun onStatusChanged(status: BleStatus) { override fun onStatusChanged(status: BleStatus) {
when (status) { when (status) {
BleStatus.Connected -> { BleStatus.Connected -> {
@ -99,10 +116,10 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
val settingsContainer = findViewById<android.view.View>(R.id.settingsContainer) val settingsContainer = findViewById<android.view.View>(R.id.settingsContainer)
settingsContainer.setOnClickListener { settingsContainer.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
} }
findViewById<ImageButton>(R.id.settingsButton).setOnClickListener { findViewById<ImageButton>(R.id.settingsButton).setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
} }
setupRelayButton() setupRelayButton()
setupRelay2Button() setupRelay2Button()
@ -110,18 +127,6 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
relay2Button.isEnabled = true relay2Button.isEnabled = true
} }
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) {
triedPermissions = false
manager.disconnect()
manager.resetTriedAddresses()
manager.updateKeys()
handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
@ -135,7 +140,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
if (!manager.hasPermissions()) { if (!manager.hasPermissions()) {
if (!triedPermissions) { if (!triedPermissions) {
triedPermissions = true triedPermissions = true
ActivityCompat.requestPermissions(this, manager.getRequiredPermissions(), 1) permissionLauncher.launch(manager.getRequiredPermissions())
} }
return return
} }
@ -150,19 +155,6 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
manager.stopScan() manager.stopScan()
} }
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
manager.startScan()
} else {
triedPermissions = false
onStatusChanged(BleStatus.PermissionsDenied)
}
}
}
private fun setupRelayButton() { private fun setupRelayButton() {
relay1Button.setOnClickListener(null) relay1Button.setOnClickListener(null)
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_hold) loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_hold)
@ -230,7 +222,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread
if (espVersion.isNotEmpty() && espVersion != appVersion) { if (espVersion.isNotEmpty() && espVersion != appVersion) {
Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.ota_update_available), Toast.LENGTH_SHORT).show()
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
} }
} }
} }

View file

@ -3,12 +3,16 @@ package nl.eddydevink.sylda
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object PrefsHelper { object PrefsHelper {
private const val TAG = "PrefsHelper" private const val TAG = "PrefsHelper"
const val PREFS_NAME = "relay_prefs" const val PREFS_NAME = "relay_prefs"
private const val ENCRYPTED_PREFS_NAME = "encrypted_prefs"
const val PREF_KEY = "psk" const val PREF_KEY = "psk"
const val PREF_UUID = "device_uuid" const val PREF_UUID = "device_uuid"
const val PREF_SEQ = "seq_counter" const val PREF_SEQ = "seq_counter"
@ -20,6 +24,49 @@ object PrefsHelper {
const val KEY_CLIENT_UUID = "clientUuid" const val KEY_CLIENT_UUID = "clientUuid"
const val SEQ_MAX = 1_000_000 const val SEQ_MAX = 1_000_000
private var masterKey: MasterKey? = null
private fun getMasterKey(context: Context): MasterKey {
return masterKey ?: run {
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build().also { masterKey = it }
}
}
fun getEncryptedPrefs(context: Context): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
getMasterKey(context),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun migrateCredentialsIfNeeded(context: Context) {
val plainPrefs = getPrefs(context)
val encPrefs = getEncryptedPrefs(context)
val legacyKey = plainPrefs.getString(PREF_KEY, null)
val legacyUuid = plainPrefs.getString(PREF_UUID, null)
if (legacyKey != null && !encPrefs.contains(PREF_KEY)) {
Log.i(TAG, "Migrating credentials to encrypted storage")
encPrefs.edit().apply {
putString(PREF_KEY, legacyKey)
if (legacyUuid != null) putString(PREF_UUID, legacyUuid)
apply()
}
plainPrefs.edit().apply {
remove(PREF_KEY)
remove(PREF_UUID)
apply()
}
Log.i(TAG, "Credentials migration complete")
}
}
fun getDeviceMode(context: Context): String = getPrefs(context).getString(KEY_DEVICE_MODE, "server") ?: "server" 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 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 getTransportMode(context: Context): String = getPrefs(context).getString("transportMode", "ble") ?: "ble"
@ -29,6 +76,11 @@ object PrefsHelper {
fun getClientUuid(context: Context): String? = getPrefs(context).getString(KEY_CLIENT_UUID, null) 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 setClientUuid(context: Context, uuid: String) = getPrefs(context).edit().putString(KEY_CLIENT_UUID, uuid).apply()
fun getPsk(context: Context): String? = getEncryptedPrefs(context).getString(PREF_KEY, null)
fun setPsk(context: Context, psk: String) = getEncryptedPrefs(context).edit().putString(PREF_KEY, psk).apply()
fun getDeviceCredentialUuid(context: Context): String? = getEncryptedPrefs(context).getString(PREF_UUID, null)
fun setDeviceCredentialUuid(context: Context, uuid: String) = getEncryptedPrefs(context).edit().putString(PREF_UUID, uuid).apply()
fun applyTheme(context: Context) { fun applyTheme(context: Context) {
val prefs = getPrefs(context) val prefs = getPrefs(context)
val mode = when (prefs.getString(PREF_THEME, "system")) { val mode = when (prefs.getString(PREF_THEME, "system")) {

View file

@ -372,9 +372,8 @@ class SettingsActivity : AppCompatActivity() {
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) { private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER) showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER)
val prefs = PrefsHelper.getPrefs(this) val appUuid = PrefsHelper.getDeviceCredentialUuid(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null) val appKey = PrefsHelper.getPsk(this)
val appKey = prefs.getString(PrefsHelper.PREF_KEY, null)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val result = readKeyFromEsp(usbManager, device, appUuid, appKey) val result = readKeyFromEsp(usbManager, device, appUuid, appKey)
@ -384,10 +383,8 @@ class SettingsActivity : AppCompatActivity() {
progressDialog = null progressDialog = null
when (result) { when (result) {
is KeyResult.Success -> { is KeyResult.Success -> {
prefs.edit() PrefsHelper.setDeviceCredentialUuid(this@SettingsActivity, result.deviceUuid)
.putString(PrefsHelper.PREF_UUID, result.deviceUuid) PrefsHelper.setPsk(this@SettingsActivity, result.hexKey)
.putString(PrefsHelper.PREF_KEY, result.hexKey)
.apply()
if (PrefsHelper.getDeviceMode(this@SettingsActivity) == "server") { if (PrefsHelper.getDeviceMode(this@SettingsActivity) == "server") {
PrefsHelper.setServerUuid(this@SettingsActivity, result.deviceUuid) PrefsHelper.setServerUuid(this@SettingsActivity, result.deviceUuid)
} else { } else {
@ -838,8 +835,7 @@ class SettingsActivity : AppCompatActivity() {
private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice, private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice,
espUuid: String, espKey: String, appKey: String) { espUuid: String, espKey: String, appKey: String) {
val prefs = PrefsHelper.getPrefs(this) val appUuid = PrefsHelper.getDeviceCredentialUuid(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
val newAppKey = appKey.ifEmpty { generateRandomHexKey() } val newAppKey = appKey.ifEmpty { generateRandomHexKey() }
val newAppUuid = appUuid ?: generateRandomHex(UUID_BYTE_COUNT) val newAppUuid = appUuid ?: generateRandomHex(UUID_BYTE_COUNT)
@ -946,11 +942,8 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
val prefs = PrefsHelper.getPrefs(this) PrefsHelper.setDeviceCredentialUuid(this, newUuid)
prefs.edit() PrefsHelper.setPsk(this, appKey)
.putString(PrefsHelper.PREF_UUID, newUuid)
.putString(PrefsHelper.PREF_KEY, appKey)
.apply()
if (PrefsHelper.getDeviceMode(this) == "server") { if (PrefsHelper.getDeviceMode(this) == "server") {
PrefsHelper.setServerUuid(this, newUuid) PrefsHelper.setServerUuid(this, newUuid)
} else { } else {

View file

@ -0,0 +1,181 @@
# Auto-detect Upload Port Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix auto-detect upload port failure when active log output interferes with ID? response detection.
**Architecture:** Two-part fix — script-side filtering continues reading until ID match, firmware-side serial mute pauses all output during ID? response.
**Tech Stack:** Python 3 + pyserial (script), ESP32-S3 + Arduino + ESP-IDF logging (firmware)
---
## File Structure
| File | Action | Purpose |
|------|--------|---------|
| `tools/pio_upload_port.py` | Modify | Filter ID responses in query_id() |
| `src/main.cpp` | Modify | Add serial mute for server |
| `src/client.cpp` | Modify | Add serial mute for client |
---
### Task 1: Script-side ID filtering
**Files:**
- Modify: `tools/pio_upload_port.py:46-61`
- [ ] **Step 1: Update query_id() to filter for ID response**
Replace lines 46-61 in `tools/pio_upload_port.py`:
```python
def query_id(port, timeout=5):
try:
ser = serial.Serial(port, 115200, timeout=0.5)
time.sleep(0.1)
ser.reset_input_buffer()
ser.write(b"ID?\n")
deadline = time.time() + timeout
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if "SylDa_Server" in line or "SylDa_Client" in line:
ser.close()
return line
ser.close()
except (serial.SerialException, OSError):
pass
return None
```
- [ ] **Step 2: Commit**
```bash
git add tools/pio_upload_port.py
git commit -m "fix(script): filter ID response in query_id()"
```
---
### Task 2: Server firmware serial mute
**Files:**
- Modify: `src/main.cpp`
- [ ] **Step 1: Add serialMuted flag and mute helper**
After line 17 (`static const char *TAG = "server";`), add:
```cpp
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", CONFIG_LOG_DEFAULT_LEVEL);
}
```
- [ ] **Step 2: Update ID? handler to use mute**
Replace lines 591-592:
```cpp
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Server");
delay(50);
unmuteSerial();
```
- [ ] **Step 3: Commit**
```bash
git add src/main.cpp
git commit -m "fix(server): add serial mute during ID? response"
```
---
### Task 3: Client firmware serial mute
**Files:**
- Modify: `src/client.cpp`
- [ ] **Step 1: Add serialMuted flag and mute helper**
After line 15 (`static const char *TAG = "client";`), add:
```cpp
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", CONFIG_LOG_DEFAULT_LEVEL);
}
```
- [ ] **Step 2: Update ID? handler to use mute**
Replace lines 425-426:
```cpp
} else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Client");
delay(50);
unmuteSerial();
```
- [ ] **Step 3: Commit**
```bash
git add src/client.cpp
git commit -m "fix(client): add serial mute during ID? response"
```
---
### Task 4: Build and verify
- [ ] **Step 1: Build both environments**
```bash
pio run -e s3lite-server
pio run -e s3lite-client
```
Expected: Both compile without errors.
- [ ] **Step 2: Verify firmware version bump**
Check `version.txt` exists with correct format.
- [ ] **Step 3: Push branch**
```bash
git push -u origin fix/issue-16-auto-detect-port
```
---
## Testing Checklist
After implementation, test with actual hardware:
1. Connect both server and client via USB
2. Run `pio run -e s3lite-client -t upload` — should auto-detect client
3. Run `pio run -e s3lite-server -t upload` — should auto-detect server
4. Verify log output resumes after upload completes
5. Test during active ECDH handshake (worst case)

View file

@ -0,0 +1,152 @@
# Auto-detect Upload Port Fix
**Issue:** #16
**Date:** 2026-08-04
**Status:** Approved
## Problem
The post-script `tools/pio_upload_port.py` sends `ID?` to the ESP and reads the first non-empty response to determine device type (server/client). The client responds correctly with `SylDa_Client`, but during upload there was active log output (ECDH handshake) on the serial port. The `query_id()` function reads the first log message instead of the ID response, causing auto-detect to fail.
Root cause: `query_id()` blindly takes the first non-empty line, even if it's log output.
## Solution
Two-part fix for maximum robustness:
1. **Script-side:** Filter responses — keep reading until a line containing `SylDa_Server` or `SylDa_Client` appears
2. **Firmware-side:** Pause all serial output during `ID?` response
No protocol changes. Both parts work independently but together provide robustness.
## Architecture
```
┌─────────────────────┐ ID?\n ┌─────────────────────┐
│ pio_upload_port │ ─────────────▶│ Firmware (ESP32) │
│ (Python script) │ │ │
│ │◀─────────────│ serialMuted=true │
│ query_id() reads │ SylDa_* │ Serial.flush() │
│ until match │ │ response │
└─────────────────────┘ │ serialMuted=false │
└─────────────────────┘
```
## Components
### 1. Script: `tools/pio_upload_port.py`
**Change:** `query_id()` function filters for expected ID instead of returning first non-empty line.
**Before:**
```python
def query_id(port, timeout=5):
# ...
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
ser.close()
return line # Returns first non-empty, even if log
# ...
```
**After:**
```python
def query_id(port, timeout=5):
try:
ser = serial.Serial(port, 115200, timeout=0.5)
time.sleep(0.1)
ser.reset_input_buffer()
ser.write(b"ID?\n")
deadline = time.time() + timeout
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if "SylDa_Server" in line or "SylDa_Client" in line:
ser.close()
return line
ser.close()
except (serial.SerialException, OSError):
pass
return None
```
**No other changes needed** — the rest of the script already uses `expected_id in resp` checks.
### 2. Firmware: `src/client.cpp` and `src/server.cpp`
**Changes:**
1. Global `serialMuted` flag
2. Serial mute during `ID?` handling
3. Wrapper macros for existing Serial calls
**Implementation:**
```cpp
// Global flag (file scope)
static bool serialMuted = false;
// Wrapper macros
#define SERIAL_PRINTLN(msg) if (!serialMuted) Serial.println(msg)
#define SERIAL_PRINT(msg) if (!serialMuted) Serial.print(msg)
// In serial command handling
} else if (serialBuf == "ID?") {
Serial.flush();
serialMuted = true;
Serial.println("SylDa_Client"); // or SylDa_Server
delay(50); // Ensure response transmitted
serialMuted = false;
}
```
**Existing `Serial.println()` calls:** Replace with `SERIAL_PRINTLN()` macro.
**ESP_LOG macros:** Unchanged — these use IDF logging, not Arduino Serial.
### 3. Server firmware
Identical implementation to client, with `SylDa_Server` response.
## Data Flow
1. Script opens serial port, sends `ID?\n`
2. Firmware receives command:
- `Serial.flush()` empties TX buffer
- `serialMuted = true` blocks new output
- `Serial.println("SylDa_Client")` — only output
- `delay(50)` ensures transmission
- `serialMuted = false` releases mute
3. Script reads lines until `SylDa_*` appears (timeout 5s)
4. Match → port selected
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No response | Script timeout → manual port selection (existing) |
| Wrong response | Script detects mismatch → manual selection (existing) |
| Multiple ports, both respond | Script matches correct one, selects it |
| Firmware mute stuck | Impossible — mute released immediately after response |
| Buffer overflow | `Serial.flush()` prevents this |
## Testing
1. Connect both server and client simultaneously
2. Run `pio run -e s3lite-client -t upload` — should auto-detect client
3. Run `pio run -e s3lite-server -t upload` — should auto-detect server
4. Verify log output resumes after `ID?` response
5. Test with active ECDH handshake in progress
## Files Changed
- `tools/pio_upload_port.py` — `query_id()` filtering
- `src/client.cpp` — serial mute implementation
- `src/server.cpp` — serial mute implementation
## Rollback
If issues arise, revert to manual port selection:
```bash
pio run -e s3lite-client -t upload --upload-port /dev/serial/by-id/...
```

View file

@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""BLE client for SylDa_Server — send color commands from laptop."""
import asyncio
import sys
from bleak import BleakScanner, BleakClient
SERVICE_UUID = "0000abcd-0000-1000-8000-00805f9b34fb"
CHAR_UUID = "0000abce-0000-1000-8000-00805f9b34fb"
SERVER_NAME = "SylDa_Server"
COLORS = {
"r": ("red", b"\x00"),
"g": ("green", b"\x01"),
"y": ("yellow", b"\x02"),
"o": ("orange", b"\x03"),
"b": ("blue", b"\x04"),
}
async def find_server():
print(f"Scanning for {SERVER_NAME}...")
device = await BleakScanner.find_device_by_name(SERVER_NAME, timeout=10)
if not device:
print("Not found.")
return None
print(f"Found: {device.name} ({device.address})")
return device
async def send_color(client, data):
await client.write_gatt_char(CHAR_UUID, data, response=True)
print(f"Sent: {COLORS[[k for k, v in COLORS.items() if v[1] == data][0]][0]}")
def show_menu():
print("\n┌─────────────────────┐")
print("│ r = red │")
print("│ g = green │")
print("│ y = yellow │")
print("│ o = orange │")
print("│ b = blue │")
print("│ q = quit │")
print("└─────────────────────┘")
async def connect_handler(client):
print(f"Connected: {client.is_connected}")
async def disconnect_handler(client):
print("Disconnected from server.")
async def main():
device = await find_server()
if not device:
return
async with BleakClient(
device,
disconnected_callback=disconnect_handler,
timeout=10,
) as client:
await connect_handler(client)
while True:
try:
show_menu()
cmd = await asyncio.get_event_loop().run_in_executor(
None, lambda: input("> ").strip().lower()
)
except (EOFError, KeyboardInterrupt):
break
if cmd == "q":
break
elif cmd in COLORS:
await send_color(client, COLORS[cmd][1])
else:
print(f"Unknown: {cmd}")
print("Bye.")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h> #include <Arduino.h>
#include <NimBLEDevice.h> #include <NimBLEDevice.h>
#include <FastLED.h> #include <FastLED.h>
@ -12,6 +13,18 @@
static const char *TAG = "client"; static const char *TAG = "client";
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", (esp_log_level_t)CONFIG_LOG_DEFAULT_LEVEL);
}
#define TOUCH1_PIN 8 #define TOUCH1_PIN 8
#define TOUCH2_PIN 6 #define TOUCH2_PIN 6
#define BUTTON_PIN 41 #define BUTTON_PIN 41
@ -247,7 +260,7 @@ static bool performEcdhHandshake(NimBLERemoteService *pSvc) {
return false; return false;
} }
memcpy(aesKey, ecdh.aes_key, AES_KEY_LEN); memcpy(aesKey, ecdh.getAesKey(), AES_KEY_LEN);
ecdh.free(); ecdh.free();
ecdhReady = true; ecdhReady = true;
ESP_LOGI(TAG, "ECDH: handshake complete"); ESP_LOGI(TAG, "ECDH: handshake complete");
@ -422,7 +435,11 @@ void loop() {
Serial.println("NO_KEY"); Serial.println("NO_KEY");
} }
} else if (serialBuf == "ID?") { } else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Client"); Serial.println("SylDa_Client");
delay(50);
unmuteSerial();
} else if (serialBuf == "VERSION?") { } else if (serialBuf == "VERSION?") {
Serial.print("BUILD_NR:"); Serial.print("BUILD_NR:");
Serial.println(FIRMWARE_VERSION_CLIENT); Serial.println(FIRMWARE_VERSION_CLIENT);

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
@ -20,6 +21,9 @@ private:
mbedtls_ctr_drbg_context ctr_drbg; mbedtls_ctr_drbg_context ctr_drbg;
bool initialized = false; bool initialized = false;
uint8_t shared_secret[32];
uint8_t aes_key[AES_KEY_LEN];
CryptoECDH(const CryptoECDH&) = delete; CryptoECDH(const CryptoECDH&) = delete;
CryptoECDH& operator=(const CryptoECDH&) = delete; CryptoECDH& operator=(const CryptoECDH&) = delete;
@ -28,8 +32,9 @@ public:
uint8_t public_key[ECDH_PUBKEY_LEN]; uint8_t public_key[ECDH_PUBKEY_LEN];
size_t pub_len = 0; size_t pub_len = 0;
uint8_t shared_secret[32];
uint8_t aes_key[AES_KEY_LEN]; const uint8_t* getAesKey() const { return aes_key; }
size_t getAesKeyLen() const { return AES_KEY_LEN; }
bool init() { bool init() {
mbedtls_ecdh_init(&ecdh); mbedtls_ecdh_init(&ecdh);

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h> #include <Arduino.h>
#include <NimBLEDevice.h> #include <NimBLEDevice.h>
#include <NimBLEAdvertising.h> #include <NimBLEAdvertising.h>
@ -15,6 +16,18 @@
static const char *TAG = "server"; static const char *TAG = "server";
static bool serialMuted = false;
static void muteSerial() {
serialMuted = true;
esp_log_level_set("*", ESP_LOG_NONE);
}
static void unmuteSerial() {
serialMuted = false;
esp_log_level_set("*", (esp_log_level_t)CONFIG_LOG_DEFAULT_LEVEL);
}
#define RELAY1_PIN 1 #define RELAY1_PIN 1
#define RELAY2_PIN 2 #define RELAY2_PIN 2
#define TOUCH1_PIN 8 #define TOUCH1_PIN 8
@ -393,7 +406,7 @@ class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
return; return;
} }
memcpy(clients[idx].aesKey, clients[idx].ecdh.aes_key, AES_KEY_LEN); memcpy(clients[idx].aesKey, clients[idx].ecdh.getAesKey(), AES_KEY_LEN);
uint8_t resp[ECDH_PKT_SIZE]; uint8_t resp[ECDH_PKT_SIZE];
memcpy(resp, clients[idx].ecdh.public_key, ECDH_PUBKEY_LEN); memcpy(resp, clients[idx].ecdh.public_key, ECDH_PUBKEY_LEN);
@ -588,7 +601,11 @@ void loop() {
Serial.println("NO_KEY"); Serial.println("NO_KEY");
} }
} else if (serialBuf == "ID?") { } else if (serialBuf == "ID?") {
Serial.flush();
muteSerial();
Serial.println("SylDa_Server"); Serial.println("SylDa_Server");
delay(50);
unmuteSerial();
} else if (serialBuf == "VERSION?") { } else if (serialBuf == "VERSION?") {
Serial.flush(); Serial.flush();
Serial.print("BUILD_NR:"); Serial.print("BUILD_NR:");

View file

@ -52,7 +52,7 @@ def query_id(port, timeout=5):
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip() line = ser.readline().decode("utf-8", errors="ignore").strip()
if line: if "SylDa_Server" in line or "SylDa_Client" in line:
ser.close() ser.close()
return line return line
ser.close() ser.close()

123
tools/test_ota_notify.py Normal file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Test OTA notify flow: writes 0x02 to server's CLIENT_OTA_STATUS via BLE,
then checks client serial for peripheral-mode entry log."""
import sys, time, asyncio, serial, threading, os
from bleak import BleakScanner, BleakClient
SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
STATUS_UUID = "beb5483e-36e1-4688-b7f5-ea07361f26df"
SERVER_NAME = "SylDa_Server"
CLIENT_PORT = os.environ.get("SYLDA_CLIENT_PORT", "/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_44:1B:F6:F5:D1:E0-if00")
TIMEOUT_S = 25
class TestResult:
def __init__(self):
self.pass_marker = False
self.fail_marker = False
self.lines = []
def monitor_serial(stop_event, result):
try:
ser = serial.Serial(CLIENT_PORT, 115200, timeout=0.5)
print(f"[TEST] Serial opened: {CLIENT_PORT}")
while not stop_event.is_set():
try:
data = ser.read(1024)
if data:
text = data.decode("ascii", errors="replace")
for line in text.splitlines():
if line.strip():
result.lines.append(line)
if "peripheral" in line.lower():
result.pass_marker = True
stop_event.set()
print(f"[TEST] PASS: {line}")
if "OTA: received trigger" in line or "entering peripheral" in line:
result.pass_marker = True
stop_event.set()
print(f"[TEST] PASS: {line}")
except serial.SerialException:
result.fail_marker = True
stop_event.set()
print("[TEST] FAIL: serial exception")
except Exception as e:
result.fail_marker = True
stop_event.set()
print(f"[TEST] FAIL: {e}")
finally:
try: ser.close()
except: pass
async def ble_write_02(result):
print("[TEST] Scanning for SylDa_Server...")
try:
device = await BleakScanner.find_device_by_name(SERVER_NAME, timeout=10)
except Exception as e:
print(f"[TEST] FAIL: BLE scan error: {e}")
result.fail_marker = True
return
if device is None:
print("[TEST] FAIL: SylDa_Server not found via BLE scan")
result.fail_marker = True
return
print(f"[TEST] Found: {device.name} ({device.address}), connecting...")
try:
async with BleakClient(device, timeout=10) as client:
print(f"[TEST] Connected, writing 0x02 to {STATUS_UUID}...")
svc = client.services.get_service(SERVICE_UUID)
if svc is None:
print("[TEST] FAIL: service not found")
result.fail_marker = True
return
char = svc.get_characteristic(STATUS_UUID)
if char is None:
print("[TEST] FAIL: CLIENT_OTA_STATUS characteristic not found")
result.fail_marker = True
return
await client.write_gatt_char(char, b'\x02', response=True)
print("[TEST] Wrote 0x02 OK, waiting for client to enter peripheral mode...")
except Exception as e:
print(f"[TEST] FAIL: BLE write error: {e}")
result.fail_marker = True
return
async def main():
result = TestResult()
stop = threading.Event()
serial_thread = threading.Thread(target=monitor_serial, args=(stop, result), daemon=True)
serial_thread.start()
await asyncio.sleep(2)
await ble_write_02(result)
if not stop.is_set():
print(f"[TEST] Waiting up to {TIMEOUT_S}s for client response...")
stop.wait(TIMEOUT_S)
stop.set()
serial_thread.join(timeout=3)
if result.pass_marker:
print("[TEST] ===== PASS =====")
return 0
elif result.fail_marker:
print("[TEST] ===== FAIL =====")
print("[TEST] Serial lines captured:")
for line in result.lines[-20:]:
print(f" {line}")
return 1
else:
print("[TEST] ===== INDETERMINATE (timeout, no pass/fail marker) =====")
print("[TEST] Serial lines captured:")
for line in result.lines[-20:]:
print(f" {line}")
return 2
if __name__ == "__main__":
rc = asyncio.run(main())
sys.exit(rc)