# BLE Refactor: BleRelayManager Extractie > **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:** Extractie van alle BLE-logica uit MainActivity naar een aparte BleRelayManager klasse met callback interface. **Architecture:** BleRelayManager beheert scan, GATT, UUID verificatie, HMAC relay commands, key/seq in PrefsHelper. MainActivity wordt puur UI + lifecycle en reageert op BleCallbacks.onStatusChanged(). Touch listeners sturen commands naar de manager. **Tech Stack:** Kotlin, Android BLE API, PrefsHelper (SharedPreferences), javax.crypto.Mac --- ## File Structure | Bestand | Actie | Verantwoordelijk | |---|---|---| | `BleStatus.kt` | Create | `BleStatus` enum + `BleCallbacks` interface | | `BleRelayManager.kt` | Create | Alle BLE-logica: scan, GATT, UUID, HMAC, keys, seq, auto-retry | | `MainActivity.kt` | Modify | UI + lifecycle, delegeert naar BleRelayManager | --- ### Task 1: BleStatus enum + BleCallbacks interface **Files:** - Create: `android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt` - [ ] **Step 1: Schrijf BleStatus.kt** ```kotlin // SPDX-License-Identifier: GPL-3.0-or-later package com.eddy.atomrelay enum class BleStatus { Scanning, Connecting, Connected, Disconnected, NoKey, WrongDevice, ServiceNotFound, ServiceError, CommError, DeviceNotFound, BluetoothDisabled, PermissionsDenied } interface BleCallbacks { fun onStatusChanged(status: BleStatus) } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt git commit -m "feat: add BleStatus enum and BleCallbacks interface" ``` --- ### Task 2: BleRelayManager — class skeleton + companion **Files:** - Create: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` - [ ] **Step 1: Schrijf class skeleton** ```kotlin // SPDX-License-Identifier: GPL-3.0-or-later package com.eddy.atomrelay import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.content.Context import android.os.Handler import android.os.Looper import java.util.UUID class BleRelayManager( private val context: Context, private val callbacks: BleCallbacks ) { companion object { private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9") private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L private const val MAX_SCAN_RETRIES = 3 } private var bluetoothGatt: BluetoothGatt? = null private var relayCharacteristic: BluetoothGattCharacteristic? = null private var isScanning = false private var scanTimedOut = false private var scanTimeoutRetries = 0 private val handler = Handler(Looper.getMainLooper()) private var seq = 1 private var hmacKey: ByteArray? = null private var deviceUuid: ByteArray? = null private var triedAddresses = mutableSetOf() fun startScan() {} fun stopScan() {} fun sendRelayCommand(cmd: Byte) {} fun disconnect() {} fun updateKeys() {} fun resetTriedAddresses() {} fun hasPermissions(): Boolean = false fun getRequiredPermissions(): Array = emptyArray() fun destroy() {} } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt git commit -m "feat: add BleRelayManager class skeleton" ``` --- ### Task 3: BleRelayManager — private helpers (loadState, key/HMAC) **Files:** - Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` - [ ] **Step 1: Voeg imports en private helpers toe** Nieuwe imports: ```kotlin import java.nio.ByteBuffer import java.nio.ByteOrder import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec ``` Verwijder `fun startScan() {}` etc stubs. Nieuwe companion constants: ```kotlin private const val HMAC_PKT_SIZE = 37 private const val HMAC_LEN = 32 ``` Nieuwe methodes: ```kotlin fun updateKeys() { loadState() } fun resetTriedAddresses() { triedAddresses.clear() } fun destroy() { handler.removeCallbacksAndMessages(null) } private fun loadState() { val prefs = PrefsHelper.getPrefs(context) val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) hmacKey = hexKey?.let { hexStringToBytes(it) } val hexUuid = prefs.getString(PrefsHelper.PREF_UUID, null) deviceUuid = hexUuid?.let { hexStringToBytes(it) } seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) } private fun hexStringToBytes(hex: String): ByteArray? { val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" } if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key, "HmacSHA256")) return mac.doFinal(data) } private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray { val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() val hmacData = ByteArray(5) hmacData[0] = cmd System.arraycopy(seqBytes, 0, hmacData, 1, 4) val hmac = hmacSha256(hmacData, key) val packet = ByteArray(HMAC_PKT_SIZE) packet[0] = cmd System.arraycopy(seqBytes, 0, packet, 1, 4) System.arraycopy(hmac, 0, packet, 5, HMAC_LEN) return packet } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt git commit -m "feat: add BleRelayManager key/HMAC helpers and loadState" ``` --- ### Task 4: BleRelayManager — scan callback + startScan/stopScan **Files:** - Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` - [ ] **Step 1: Voeg scan logic toe** Nieuwe imports: ```kotlin import android.Manifest import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.content.pm.PackageManager import android.os.ParcelUuid import androidx.core.app.ActivityCompat ``` Nieuwe velden + scanCallback: ```kotlin private val scanRunnable = Runnable { startScan() } private var scanTimeoutRunnable: Runnable? = null private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device ?: return val address = device.address ?: return if (triedAddresses.contains(address)) return stopScan() connectToDevice(device) } } ``` Methodes startScan, stopScan, hasPermissions, getRequiredPermissions, connectToDevice: ```kotlin @SuppressLint("MissingPermission") fun startScan() { if (isScanning) return if (!hasPermissions()) { callbacks.onStatusChanged(BleStatus.PermissionsDenied) return } val adapter = BluetoothAdapter.getDefaultAdapter() if (adapter == null || !adapter.isEnabled) { callbacks.onStatusChanged(BleStatus.BluetoothDisabled) return } loadState() if (hmacKey == null || deviceUuid == null) { callbacks.onStatusChanged(BleStatus.NoKey) return } isScanning = true scanTimedOut = false scanTimeoutRetries = 0 callbacks.onStatusChanged(BleStatus.Scanning) val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() adapter.bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) scanTimeoutRunnable?.let { handler.removeCallbacks(it) } scanTimeoutRunnable = Runnable { if (isScanning) { scanTimedOut = true stopScan() } } handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS) } @SuppressLint("MissingPermission") fun stopScan() { if (!isScanning) return isScanning = false scanTimeoutRunnable?.let { handler.removeCallbacks(it) } val adapter = BluetoothAdapter.getDefaultAdapter() adapter?.bluetoothLeScanner?.stopScan(scanCallback) if (scanTimedOut) { if (scanTimeoutRetries < MAX_SCAN_RETRIES) { scanTimeoutRetries++ handler.postDelayed(scanRunnable, 2000L) return } callbacks.onStatusChanged(BleStatus.DeviceNotFound) } } fun hasPermissions(): Boolean { return getRequiredPermissions().all { ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } fun getRequiredPermissions(): Array { return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.ACCESS_FINE_LOCATION ) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } } @SuppressLint("MissingPermission") private fun connectToDevice(device: android.bluetooth.BluetoothDevice) { callbacks.onStatusChanged(BleStatus.Connecting) bluetoothGatt = device.connectGatt(context, false, gattCallback) } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt git commit -m "feat: add BleRelayManager scan logic with permissions" ``` --- ### Task 5: BleRelayManager — GATT callback (connect, services, UUID verify, notifications) **Files:** - Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` - [ ] **Step 1: Voeg GATT callback toe** Nieuwe imports: ```kotlin import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile import android.util.Log ``` Voeg lege gattCallback stub toe (die was er nog niet) of vervang bestaande lege: ```kotlin private val gattCallback = object : android.bluetooth.BluetoothGattCallback() { @SuppressLint("MissingPermission") override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { if (hmacKey == null) { Log.w("BleRelayManager", "GATT connected but no key -- disconnecting") callbacks.onStatusChanged(BleStatus.NoKey) gatt.disconnect() return } Log.d("BleRelayManager", "GATT connected") bluetoothGatt = gatt gatt.discoverServices() } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { Log.d("BleRelayManager", "GATT disconnected") callbacks.onStatusChanged(BleStatus.Disconnected) gatt.close() bluetoothGatt = null relayCharacteristic = null seq = 1 handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS) } } @SuppressLint("MissingPermission") override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { Log.w("BleRelayManager", "Service discovery failed (status=$status)") callbacks.onStatusChanged(BleStatus.ServiceError) return } Log.d("BleRelayManager", "Services discovered, requesting MTU...") gatt.requestMtu(256) } @SuppressLint("MissingPermission") override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) { Log.d("BleRelayManager", "MTU changed to $mtu") val service = gatt.getService(SERVICE_UUID) if (service != null) { relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) if (relayCharacteristic != null && uuidChar != null) { Log.d("BleRelayManager", "Reading device UUID characteristic...") gatt.readCharacteristic(uuidChar) } else { Log.e("BleRelayManager", "Service or characteristics not found") callbacks.onStatusChanged(BleStatus.ServiceNotFound) } } else { Log.e("BleRelayManager", "Service not found") callbacks.onStatusChanged(BleStatus.ServiceNotFound) } } @SuppressLint("MissingPermission") override fun onCharacteristicRead(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, value: ByteArray, status: Int) { if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) { val storedUuid = deviceUuid if (storedUuid != null && value.contentEquals(storedUuid)) { Log.d("BleRelayManager", "UUID verified, setting up relay") relayCharacteristic?.let { rc -> gatt.setCharacteristicNotification(rc, true) val descriptor = rc.getDescriptor(CCCD_UUID) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) } callbacks.onStatusChanged(BleStatus.Connected) } else { Log.w("BleRelayManager", "UUID mismatch -- disconnecting") triedAddresses.add(gatt.device.address) callbacks.onStatusChanged(BleStatus.WrongDevice) gatt.disconnect() } } } @SuppressLint("MissingPermission") override fun onCharacteristicWrite(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { Log.w("BleRelayManager", "Write failed (status=$status)") callbacks.onStatusChanged(BleStatus.CommError) } } } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt git commit -m "feat: add BleRelayManager GATT callback (connect, UUID verify, notifications)" ``` --- ### Task 6: BleRelayManager — sendRelayCommand + disconnect **Files:** - Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` - [ ] **Step 1: Implementeer sendRelayCommand en disconnect** Vervang de stubs voor `sendRelayCommand` en `disconnect`: ```kotlin @SuppressLint("MissingPermission") fun sendRelayCommand(cmd: Byte) { val char = relayCharacteristic ?: return val gatt = bluetoothGatt ?: return val key = hmacKey ?: return val packet = buildPacket(cmd, key) char.value = packet char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE gatt.writeCharacteristic(char) seq++ if (seq > PrefsHelper.SEQ_MAX) seq = 1 PrefsHelper.getPrefs(context).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply() } @SuppressLint("MissingPermission") fun disconnect() { handler.removeCallbacks(scanRunnable) scanTimeoutRunnable?.let { handler.removeCallbacks(it) } stopScan() bluetoothGatt?.disconnect() } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt git commit -m "feat: add BleRelayManager sendRelayCommand and disconnect" ``` --- ### Task 7: Refactor MainActivity — gebruik BleRelayManager **Files:** - Modify: `android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt` - [ ] **Step 1: Herschrijf MainActivity.kt** Vervang de volledige file content: ```kotlin // SPDX-License-Identifier: GPL-3.0-or-later package com.eddy.atomrelay import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.MotionEvent import android.widget.Button import android.widget.ImageButton import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat 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 } private lateinit var manager: BleRelayManager private val handler = Handler(Looper.getMainLooper()) private lateinit var statusText: TextView private lateinit var relay1Button: Button private lateinit var relay2Button: Button private var lastTheme: String? = null override fun onStatusChanged(status: BleStatus) { when (status) { BleStatus.Connected -> { statusText.text = getString(R.string.status_connected) setupRelayButton() setupRelay2Button() relay1Button.isEnabled = true relay2Button.isEnabled = true } BleStatus.Disconnected -> { statusText.text = getString(R.string.status_connecting) relay1Button.isEnabled = false relay2Button.isEnabled = false } BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning) BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting) BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key) BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device) BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found) BleStatus.ServiceError -> statusText.text = getString(R.string.status_service_error) BleStatus.CommError -> statusText.text = getString(R.string.status_comm_error) BleStatus.BluetoothDisabled -> statusText.text = getString(R.string.status_bluetooth_disabled) BleStatus.PermissionsDenied -> { statusText.text = getString(R.string.status_permissions_denied) relay1Button.isEnabled = false relay2Button.isEnabled = false } BleStatus.DeviceNotFound -> { relay1Button.setOnTouchListener(null) relay2Button.setOnTouchListener(null) relay1Button.setOnClickListener(null) relay2Button.setOnClickListener(null) relay1Button.text = getString(R.string.button_retry_scan) relay2Button.text = getString(R.string.button_hold) relay2Button.isEnabled = false relay1Button.isEnabled = true relay1Button.setOnClickListener { setupRelayButton() setupRelay2Button() manager.startScan() } } } } override fun onCreate(savedInstanceState: Bundle?) { lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" PrefsHelper.applyTheme(this) super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) manager = BleRelayManager(this, this) statusText = findViewById(R.id.statusText) relay1Button = findViewById(R.id.relay1Button) relay2Button = findViewById(R.id.relay2Button) val settingsButton = findViewById(R.id.settingsButton) settingsButton.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } setupRelayButton() setupRelay2Button() } @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) { 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" if (current != lastTheme) { lastTheme = current recreate() return } if (!manager.hasPermissions()) { requestPermissions() return } manager.resetTriedAddresses() handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS) } override fun onPause() { super.onPause() 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 { onStatusChanged(BleStatus.PermissionsDenied) } } } private fun setupRelayButton() { relay1Button.setOnClickListener(null) relay1Button.text = getString(R.string.button_hold) relay1Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { manager.sendRelayCommand(0x01.toByte()) relay1Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { manager.sendRelayCommand(0x00.toByte()) relay1Button.text = getString(R.string.button_hold) true } else -> false } } } private fun setupRelay2Button() { relay2Button.setOnClickListener(null) relay2Button.text = getString(R.string.button_hold) relay2Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { manager.sendRelayCommand(0x02.toByte()) relay2Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { manager.sendRelayCommand(0x03.toByte()) relay2Button.text = getString(R.string.button_hold) true } else -> false } } } private fun requestPermissions() { val perms = manager.getRequiredPermissions() ActivityCompat.requestPermissions(this, perms, 1) } override fun onDestroy() { super.onDestroy() handler.removeCallbacksAndMessages(null) manager.destroy() } } ``` - [ ] **Step 2: Build verificatie** Run: `./gradlew assembleDebug` Workdir: `android/` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt git commit -m "refactor: extract BLE logic from MainActivity to BleRelayManager" ``` --- ### Task 8: Functionele verificatie — deploy en test - [ ] **Step 1: Deploy APK naar telefoon** Gebruik de adb-wireless skill om te verbinden, dan: ```bash adb -s 192.168.1.43:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk ``` - [ ] **Step 2: Test BLE scan, connect, UUID verify** Start de app. Verifieer: - Status: "Scannen..." -> "Verbinden..." -> "Verbonden" - Relay 1 en 2 buttons enabled - HMAC key en UUID correct geladen uit PrefsHelper - [ ] **Step 3: Test relay knoppen** - Relay 1 indrukken: relais 1 aan, button tekst "AAN" - Relay 1 loslaten: relais 1 uit, button tekst "INGEDRUKT HOUDEN" - Relay 2 indrukken: relais 2 aan, button tekst "AAN" - Relay 2 loslaten: relais 2 uit, button tekst "INGEDRUKT HOUDEN" - Snel ON->OFF cyclen: geen vastlopers - [ ] **Step 4: Test disconnect + auto-reconnect** - ESP uitzetten, verifieer "Verbinden...", zet ESP aan, verifieer "Verbonden" - [ ] **Step 5: Test settings key exchange** - Wis keys in app, open Settings, doe key exchange - Verifieer dat app opnieuw scant en verbindt - [ ] **Step 6: Test scan timeout** - ESP uitzetten tijdens scan, verifieer na ~90s: timeout + retry knop - Druk "Opnieuw scannen", verifieer scan herstart