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 7758380..f74686d 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -22,10 +22,8 @@ import androidx.core.app.ActivityCompat import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.KeyPairGenerator -import java.security.KeyFactory import java.security.SecureRandom import java.security.spec.ECGenParameterSpec -import java.security.spec.X509EncodedKeySpec import java.security.interfaces.ECPublicKey import java.util.UUID import javax.crypto.Cipher @@ -63,25 +61,6 @@ class BleRelayManager( private const val ECDH_PKT_SIZE = 97 private const val BLE_MTU = 512 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 @@ -105,16 +84,6 @@ class BleRelayManager( private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhCccdPending = false - @Synchronized - private fun setEcdhNotifyBuf(value: ByteArray) { - ecdhNotifyBuf = value.copyOf() - } - - @Synchronized - private fun getEcdhNotifyBuf(): ByteArray { - return ecdhNotifyBuf.copyOf() - } - interface OtaNotifyCallback { 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) }}") handler.post { otaNotifyCallback?.onOtaNotify(statusVal, seqVal) } } else if (characteristic.uuid == ECDH_UUID) { - setEcdhNotifyBuf(value) + ecdhNotifyBuf = value.copyOf() ecdhDeferred?.complete(true) Log.d(TAG, "ECDH notify received (${value.size} bytes)") } @@ -537,7 +506,7 @@ class BleRelayManager( return@Thread } - val resp = getEcdhNotifyBuf() + val resp = ecdhNotifyBuf if (resp.size != ECDH_PKT_SIZE) { Log.w(TAG, "ECDH: bad response size ${resp.size}") handler.post { @@ -561,7 +530,28 @@ class BleRelayManager( val ka = KeyAgreement.getInstance("ECDH") 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) val shared = ka.generateSecret() @@ -616,13 +606,13 @@ class BleRelayManager( } private fun loadState() { - PrefsHelper.migrateCredentialsIfNeeded(context) - val hexKey = PrefsHelper.getPsk(context) + val prefs = PrefsHelper.getPrefs(context) + val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) psk = hexKey?.let { hexStringToBytes(it) } val hexUuid = PrefsHelper.getServerUuid(context) - ?: PrefsHelper.getDeviceCredentialUuid(context) + ?: prefs.getString(PrefsHelper.PREF_UUID, null) 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") } 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 e18839c..8f73a52 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt @@ -2,6 +2,7 @@ package nl.eddydevink.sylda import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Color import android.net.Uri import android.os.Bundle @@ -12,13 +13,14 @@ import android.widget.Button import android.widget.ImageButton import android.widget.TextView import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat class MainActivity : AppCompatActivity(), BleCallbacks { companion object { + private const val REQUEST_SETTINGS = 1 private const val POST_SETTINGS_RESCAN_MS = 500L private const val ON_RESUME_SCAN_DELAY_MS = 300L } @@ -32,25 +34,6 @@ class MainActivity : AppCompatActivity(), BleCallbacks { private var lastTheme: String? = null 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) { when (status) { BleStatus.Connected -> { @@ -116,10 +99,10 @@ class MainActivity : AppCompatActivity(), BleCallbacks { val settingsContainer = findViewById(R.id.settingsContainer) settingsContainer.setOnClickListener { - settingsLauncher.launch(Intent(this, SettingsActivity::class.java)) + startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } findViewById(R.id.settingsButton).setOnClickListener { - settingsLauncher.launch(Intent(this, SettingsActivity::class.java)) + startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } setupRelayButton() setupRelay2Button() @@ -127,6 +110,18 @@ class MainActivity : AppCompatActivity(), BleCallbacks { 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() { super.onResume() val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" @@ -140,7 +135,7 @@ class MainActivity : AppCompatActivity(), BleCallbacks { if (!manager.hasPermissions()) { if (!triedPermissions) { triedPermissions = true - permissionLauncher.launch(manager.getRequiredPermissions()) + ActivityCompat.requestPermissions(this, manager.getRequiredPermissions(), 1) } return } @@ -155,6 +150,19 @@ class MainActivity : AppCompatActivity(), BleCallbacks { manager.stopScan() } + @Deprecated("Deprecated in Java") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, 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() { relay1Button.setOnClickListener(null) 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 (espVersion.isNotEmpty() && espVersion != appVersion) { 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) } } } 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 f67d6c7..fdc6c45 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt @@ -3,16 +3,12 @@ package nl.eddydevink.sylda import android.content.Context import android.content.SharedPreferences -import android.util.Log import androidx.appcompat.app.AppCompatDelegate -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey object PrefsHelper { private const val TAG = "PrefsHelper" const val PREFS_NAME = "relay_prefs" - private const val ENCRYPTED_PREFS_NAME = "encrypted_prefs" const val PREF_KEY = "psk" const val PREF_UUID = "device_uuid" const val PREF_SEQ = "seq_counter" @@ -24,49 +20,6 @@ object PrefsHelper { const val KEY_CLIENT_UUID = "clientUuid" 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 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" @@ -76,11 +29,6 @@ object PrefsHelper { 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 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) { 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 6ede172..91ccaec 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt @@ -372,8 +372,9 @@ class SettingsActivity : AppCompatActivity() { private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) { showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER) - val appUuid = PrefsHelper.getDeviceCredentialUuid(this) - val appKey = PrefsHelper.getPsk(this) + val prefs = PrefsHelper.getPrefs(this) + val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null) + val appKey = prefs.getString(PrefsHelper.PREF_KEY, null) lifecycleScope.launch(Dispatchers.IO) { val result = readKeyFromEsp(usbManager, device, appUuid, appKey) @@ -383,8 +384,10 @@ class SettingsActivity : AppCompatActivity() { progressDialog = null when (result) { is KeyResult.Success -> { - PrefsHelper.setDeviceCredentialUuid(this@SettingsActivity, result.deviceUuid) - PrefsHelper.setPsk(this@SettingsActivity, result.hexKey) + prefs.edit() + .putString(PrefsHelper.PREF_UUID, result.deviceUuid) + .putString(PrefsHelper.PREF_KEY, result.hexKey) + .apply() if (PrefsHelper.getDeviceMode(this@SettingsActivity) == "server") { PrefsHelper.setServerUuid(this@SettingsActivity, result.deviceUuid) } else { @@ -835,7 +838,8 @@ class SettingsActivity : AppCompatActivity() { private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice, 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 newAppUuid = appUuid ?: generateRandomHex(UUID_BYTE_COUNT) @@ -942,8 +946,11 @@ class SettingsActivity : AppCompatActivity() { } } - PrefsHelper.setDeviceCredentialUuid(this, newUuid) - PrefsHelper.setPsk(this, appKey) + val prefs = PrefsHelper.getPrefs(this) + prefs.edit() + .putString(PrefsHelper.PREF_UUID, newUuid) + .putString(PrefsHelper.PREF_KEY, appKey) + .apply() if (PrefsHelper.getDeviceMode(this) == "server") { PrefsHelper.setServerUuid(this, newUuid) } else { diff --git a/docs/superpowers/plans/2026-08-04-auto-detect-port-fix.md b/docs/superpowers/plans/2026-08-04-auto-detect-port-fix.md deleted file mode 100644 index dfa14fa..0000000 --- a/docs/superpowers/plans/2026-08-04-auto-detect-port-fix.md +++ /dev/null @@ -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) diff --git a/docs/superpowers/specs/2026-08-04-auto-detect-port-fix.md b/docs/superpowers/specs/2026-08-04-auto-detect-port-fix.md deleted file mode 100644 index fb05d3b..0000000 --- a/docs/superpowers/specs/2026-08-04-auto-detect-port-fix.md +++ /dev/null @@ -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/... -``` diff --git a/scripts/ble_client.py b/scripts/ble_client.py new file mode 100644 index 0000000..8d42fdd --- /dev/null +++ b/scripts/ble_client.py @@ -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()) diff --git a/src/client.cpp b/src/client.cpp index f49f414..4965916 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -1,4 +1,3 @@ -// SPDX-License-Identifier: GPL-3.0-or-later #include #include #include @@ -13,18 +12,6 @@ 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 TOUCH2_PIN 6 #define BUTTON_PIN 41 @@ -260,7 +247,7 @@ static bool performEcdhHandshake(NimBLERemoteService *pSvc) { return false; } - memcpy(aesKey, ecdh.getAesKey(), AES_KEY_LEN); + memcpy(aesKey, ecdh.aes_key, AES_KEY_LEN); ecdh.free(); ecdhReady = true; ESP_LOGI(TAG, "ECDH: handshake complete"); @@ -435,11 +422,7 @@ void loop() { Serial.println("NO_KEY"); } } else if (serialBuf == "ID?") { - Serial.flush(); - muteSerial(); Serial.println("SylDa_Client"); - delay(50); - unmuteSerial(); } else if (serialBuf == "VERSION?") { Serial.print("BUILD_NR:"); Serial.println(FIRMWARE_VERSION_CLIENT); diff --git a/src/ecdh_helper.h b/src/ecdh_helper.h index d8ee9c1..edc70de 100644 --- a/src/ecdh_helper.h +++ b/src/ecdh_helper.h @@ -1,4 +1,3 @@ -// SPDX-License-Identifier: GPL-3.0-or-later #pragma once #include @@ -21,9 +20,6 @@ private: mbedtls_ctr_drbg_context ctr_drbg; bool initialized = false; - uint8_t shared_secret[32]; - uint8_t aes_key[AES_KEY_LEN]; - CryptoECDH(const CryptoECDH&) = delete; CryptoECDH& operator=(const CryptoECDH&) = delete; @@ -32,9 +28,8 @@ public: uint8_t public_key[ECDH_PUBKEY_LEN]; size_t pub_len = 0; - - const uint8_t* getAesKey() const { return aes_key; } - size_t getAesKeyLen() const { return AES_KEY_LEN; } + uint8_t shared_secret[32]; + uint8_t aes_key[AES_KEY_LEN]; bool init() { mbedtls_ecdh_init(&ecdh); diff --git a/src/main.cpp b/src/main.cpp index c330e9f..f22cbdf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,3 @@ -// SPDX-License-Identifier: GPL-3.0-or-later #include #include #include @@ -16,18 +15,6 @@ 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 RELAY2_PIN 2 #define TOUCH1_PIN 8 @@ -406,7 +393,7 @@ class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks { 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]; memcpy(resp, clients[idx].ecdh.public_key, ECDH_PUBKEY_LEN); @@ -601,11 +588,7 @@ void loop() { Serial.println("NO_KEY"); } } else if (serialBuf == "ID?") { - Serial.flush(); - muteSerial(); Serial.println("SylDa_Server"); - delay(50); - unmuteSerial(); } else if (serialBuf == "VERSION?") { Serial.flush(); Serial.print("BUILD_NR:"); diff --git a/tools/pio_upload_port.py b/tools/pio_upload_port.py index 43c9e25..0e94c81 100644 --- a/tools/pio_upload_port.py +++ b/tools/pio_upload_port.py @@ -52,7 +52,7 @@ def query_id(port, timeout=5): 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: + if line: ser.close() return line ser.close() diff --git a/tools/test_ota_notify.py b/tools/test_ota_notify.py deleted file mode 100644 index 19b2308..0000000 --- a/tools/test_ota_notify.py +++ /dev/null @@ -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)