Compare commits
12 changed files with 166 additions and 621 deletions
|
|
@ -22,10 +22,8 @@ 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
|
||||||
|
|
@ -63,25 +61,6 @@ 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
|
||||||
|
|
@ -105,16 +84,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +404,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) {
|
||||||
setEcdhNotifyBuf(value)
|
ecdhNotifyBuf = value.copyOf()
|
||||||
ecdhDeferred?.complete(true)
|
ecdhDeferred?.complete(true)
|
||||||
Log.d(TAG, "ECDH notify received (${value.size} bytes)")
|
Log.d(TAG, "ECDH notify received (${value.size} bytes)")
|
||||||
}
|
}
|
||||||
|
|
@ -537,7 +506,7 @@ class BleRelayManager(
|
||||||
return@Thread
|
return@Thread
|
||||||
}
|
}
|
||||||
|
|
||||||
val resp = getEcdhNotifyBuf()
|
val resp = ecdhNotifyBuf
|
||||||
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 {
|
||||||
|
|
@ -561,7 +530,28 @@ class BleRelayManager(
|
||||||
|
|
||||||
val ka = KeyAgreement.getInstance("ECDH")
|
val ka = KeyAgreement.getInstance("ECDH")
|
||||||
ka.init(keyPair.private)
|
ka.init(keyPair.private)
|
||||||
val serverKey = decodeEcPublicKey(serverPub)
|
val spec = java.security.spec.ECPublicKeySpec(
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
@ -616,13 +606,13 @@ class BleRelayManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadState() {
|
private fun loadState() {
|
||||||
PrefsHelper.migrateCredentialsIfNeeded(context)
|
val prefs = PrefsHelper.getPrefs(context)
|
||||||
val hexKey = PrefsHelper.getPsk(context)
|
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
|
||||||
psk = hexKey?.let { hexStringToBytes(it) }
|
psk = hexKey?.let { hexStringToBytes(it) }
|
||||||
val hexUuid = PrefsHelper.getServerUuid(context)
|
val hexUuid = PrefsHelper.getServerUuid(context)
|
||||||
?: PrefsHelper.getDeviceCredentialUuid(context)
|
?: prefs.getString(PrefsHelper.PREF_UUID, null)
|
||||||
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
|
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
|
||||||
seq = PrefsHelper.getPrefs(context).getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
|
seq = prefs.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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
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
|
||||||
|
|
@ -12,13 +13,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -32,25 +34,6 @@ 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 -> {
|
||||||
|
|
@ -116,10 +99,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 {
|
||||||
settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
|
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
|
||||||
}
|
}
|
||||||
findViewById<ImageButton>(R.id.settingsButton).setOnClickListener {
|
findViewById<ImageButton>(R.id.settingsButton).setOnClickListener {
|
||||||
settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
|
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
|
||||||
}
|
}
|
||||||
setupRelayButton()
|
setupRelayButton()
|
||||||
setupRelay2Button()
|
setupRelay2Button()
|
||||||
|
|
@ -127,6 +110,18 @@ 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"
|
||||||
|
|
@ -140,7 +135,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks {
|
||||||
if (!manager.hasPermissions()) {
|
if (!manager.hasPermissions()) {
|
||||||
if (!triedPermissions) {
|
if (!triedPermissions) {
|
||||||
triedPermissions = true
|
triedPermissions = true
|
||||||
permissionLauncher.launch(manager.getRequiredPermissions())
|
ActivityCompat.requestPermissions(this, manager.getRequiredPermissions(), 1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +150,19 @@ 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)
|
||||||
|
|
@ -222,7 +230,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()
|
||||||
settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
|
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,12 @@ 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"
|
||||||
|
|
@ -24,49 +20,6 @@ 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"
|
||||||
|
|
@ -76,11 +29,6 @@ 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")) {
|
||||||
|
|
|
||||||
|
|
@ -372,8 +372,9 @@ 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 appUuid = PrefsHelper.getDeviceCredentialUuid(this)
|
val prefs = PrefsHelper.getPrefs(this)
|
||||||
val appKey = PrefsHelper.getPsk(this)
|
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
|
||||||
|
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)
|
||||||
|
|
@ -383,8 +384,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
progressDialog = null
|
progressDialog = null
|
||||||
when (result) {
|
when (result) {
|
||||||
is KeyResult.Success -> {
|
is KeyResult.Success -> {
|
||||||
PrefsHelper.setDeviceCredentialUuid(this@SettingsActivity, result.deviceUuid)
|
prefs.edit()
|
||||||
PrefsHelper.setPsk(this@SettingsActivity, result.hexKey)
|
.putString(PrefsHelper.PREF_UUID, result.deviceUuid)
|
||||||
|
.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 {
|
||||||
|
|
@ -835,7 +838,8 @@ 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 appUuid = PrefsHelper.getDeviceCredentialUuid(this)
|
val prefs = PrefsHelper.getPrefs(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)
|
||||||
|
|
||||||
|
|
@ -942,8 +946,11 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PrefsHelper.setDeviceCredentialUuid(this, newUuid)
|
val prefs = PrefsHelper.getPrefs(this)
|
||||||
PrefsHelper.setPsk(this, appKey)
|
prefs.edit()
|
||||||
|
.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 {
|
||||||
|
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
# 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)
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
# 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/...
|
|
||||||
```
|
|
||||||
87
scripts/ble_client.py
Normal file
87
scripts/ble_client.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/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())
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// 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>
|
||||||
|
|
@ -13,18 +12,6 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -260,7 +247,7 @@ static bool performEcdhHandshake(NimBLERemoteService *pSvc) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
memcpy(aesKey, ecdh.getAesKey(), AES_KEY_LEN);
|
memcpy(aesKey, ecdh.aes_key, AES_KEY_LEN);
|
||||||
ecdh.free();
|
ecdh.free();
|
||||||
ecdhReady = true;
|
ecdhReady = true;
|
||||||
ESP_LOGI(TAG, "ECDH: handshake complete");
|
ESP_LOGI(TAG, "ECDH: handshake complete");
|
||||||
|
|
@ -435,11 +422,7 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
@ -21,9 +20,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -32,9 +28,8 @@ 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];
|
||||||
const uint8_t* getAesKey() const { return aes_key; }
|
uint8_t aes_key[AES_KEY_LEN];
|
||||||
size_t getAesKeyLen() const { return AES_KEY_LEN; }
|
|
||||||
|
|
||||||
bool init() {
|
bool init() {
|
||||||
mbedtls_ecdh_init(&ecdh);
|
mbedtls_ecdh_init(&ecdh);
|
||||||
|
|
|
||||||
19
src/main.cpp
19
src/main.cpp
|
|
@ -1,4 +1,3 @@
|
||||||
// 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>
|
||||||
|
|
@ -16,18 +15,6 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -406,7 +393,7 @@ class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
memcpy(clients[idx].aesKey, clients[idx].ecdh.getAesKey(), AES_KEY_LEN);
|
memcpy(clients[idx].aesKey, clients[idx].ecdh.aes_key, 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);
|
||||||
|
|
@ -601,11 +588,7 @@ 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:");
|
||||||
|
|
|
||||||
|
|
@ -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 "SylDa_Server" in line or "SylDa_Client" in line:
|
if line:
|
||||||
ser.close()
|
ser.close()
|
||||||
return line
|
return line
|
||||||
ser.close()
|
ser.close()
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
#!/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)
|
|
||||||
Loading…
Reference in a new issue