25 KiB
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
// 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
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
// 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<String>()
fun startScan() {}
fun stopScan() {}
fun sendRelayCommand(cmd: Byte) {}
fun disconnect() {}
fun updateKeys() {}
fun resetTriedAddresses() {}
fun hasPermissions(): Boolean = false
fun getRequiredPermissions(): Array<String> = emptyArray()
fun destroy() {}
}
- Step 2: Build verificatie
Run: ./gradlew assembleDebug
Workdir: android/
Expected: BUILD SUCCESSFUL
- Step 3: Commit
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:
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:
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
Nieuwe methodes:
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
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:
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:
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:
@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<String> {
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
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:
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:
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
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:
@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
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:
// 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<ImageButton>(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<out String>, 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
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:
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