Compare commits

..
6 changed files with 1386 additions and 339 deletions

42
AGENTS.md Normal file
View file

@ -0,0 +1,42 @@
# Project_SylDa
ESP32-S3-PICO-1-N8R8 (Atom S3R Voice), 2 relais, 2 touch sensors (JR223B), BLE, Android app USB OTG.
## Hardware
- **Board:** Atom S3R Voice (ESP32-S3-PICO-1-N8R8), 16MB OPI flash
- **Pinnen:** G1=RELAY1, G2=RELAY2, G8+pullup=TOUCH1, G6+pullup=TOUCH2, G41=BUTTON (active low), G46=LED
- **Touch:** DC3-24V Capacitieve Touch Schakelaar JR223B (GEEN TTP223). Minder C op punt A = gevoeliger = meer false triggers.
- **BLE:** "AtomS3R-Relay", service `4fafc201-1fb5-459e-8fcc-c5c9c331914b`
- **BLE cmds:** 0x00=relay1 OFF, 0x01=ON, 0x02=relay2 ON, 0x03=OFF, HMAC_PKT_SIZE=37 (1 cmd + 4 seq + 32 hmac)
- **NVS:** namespace `hmac`, key `key` — HMAC key 32 bytes, NVS op 0x9000-0xDFFF (partitie tabel)
## Workspace
`/home/eddy/src/Project_SylDa/` — S3R firmware + Android app
## Firmware — `src/main.cpp`
- PlatformIO, `platform = espressif32@6.7.0`, board `esp32-s3-devkitc-1`
- CDC USB serial (`ARDUINO_USB_CDC_ON_BOOT=1`), QIO OPI flash
- Upload laptop: `pio run -t upload`, port `/dev/ttyACM0`, 115200 baud
- NVS: namespace "hmac", key "key" (32 bytes HMAC)
- BLE advertising "AtomS3R-Relay", HMAC-verified relay commands
## Android app — `android/`
- CDC key exchange (geen FTDI), 2 relay knoppen (touch-to-hold), byte commands
- Key exchange: SettingsActivity via USB CDC (`UsbSerialTransport`)
- Flash: EspFlasher via USB CDC, `eraseFlash()` VERWIJDERD (NVS blijft behouden)
- Partitie layout flash: bootloader 0x0, partitions 0x8000, boot_app0 0xE000, firmware 0x10000
- BLE scan: 30s timeout, auto-retry 3x, reconnect na disconnect
- `deviceId` = UUID van ESP, `hmacKey` = HMAC key uit key exchange
- Bestanden: `MainActivity.kt`, `SettingsActivity.kt`, `EspFlasher.kt`, `UsbSerialTransport.kt`, `PrefsHelper.kt`, `UsbUtils.kt`
- Assets: `firmware/bootloader.bin`, `partitions.bin`, `boot_app0.bin`, `firmware.bin`
## ADB — telefoon
```
adb connect 192.168.1.43:5555
adb -s 192.168.1.43:5555 install -r app/build/outputs/apk/debug/app-debug.apk
```

View file

@ -0,0 +1,311 @@
// 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
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.util.Log
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
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
companion object {
private const val TAG = "BleRelayManager"
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 const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
}
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>()
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
Log.d(TAG, "Found device: ${device.name ?: ""} ($address)")
if (triedAddresses.contains(address)) {
Log.d(TAG, "Skipping already-tried device $address")
return
}
stopScan()
connectToDevice(device)
}
}
@SuppressLint("MissingPermission")
fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
handler.post { callbacks.onStatusChanged(BleStatus.PermissionsDenied) }
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
handler.post { callbacks.onStatusChanged(BleStatus.BluetoothDisabled) }
return
}
loadState()
if (hmacKey == null || deviceUuid == null) {
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
return
}
isScanning = true
scanTimedOut = false
scanTimeoutRetries = 0
handler.post { 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() {
handler.removeCallbacks(scanRunnable)
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
}
handler.post { 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)
}
}
fun destroy() {
handler.removeCallbacksAndMessages(null)
stopScan()
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
}
@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()
Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})")
}
@SuppressLint("MissingPermission")
fun disconnect() {
handler.removeCallbacks(scanRunnable)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
stopScan()
bluetoothGatt?.disconnect()
}
fun updateKeys() {
loadState()
}
fun resetTriedAddresses() {
triedAddresses.clear()
}
fun isConnected(): Boolean {
return bluetoothGatt != null
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
Log.d(TAG, "Connecting to ${device.address}")
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}
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(TAG, "GATT connected but no key -- disconnecting")
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
gatt.disconnect()
return
}
Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
handler.post { 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(TAG, "Service discovery failed (status=$status)")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceError) }
return
}
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(256)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "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(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
} else {
Log.e(TAG, "Service not found")
handler.post { 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(TAG, "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)
}
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
} else {
Log.w(TAG, "UUID mismatch -- disconnecting")
triedAddresses.add(gatt.device.address)
handler.post { 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(TAG, "Write failed (status=$status)")
handler.post { callbacks.onStatusChanged(BleStatus.CommError) }
}
}
}
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)
Log.d(TAG, "HMAC key loaded: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq")
}
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
}
}

View file

@ -0,0 +1,21 @@
// 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)
}

View file

@ -1,193 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
package com.eddy.atomrelay package com.eddy.atomrelay
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.UUID
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(), BleCallbacks {
companion object { companion object {
private const val TAG = "AtomRelay"
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 DEVICE_NAME = "SylDa_Server"
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
private const val REQUEST_SETTINGS = 1 private const val REQUEST_SETTINGS = 1
private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
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
private const val MAX_SCAN_RETRIES = 3
} }
private var bluetoothGatt: BluetoothGatt? = null private lateinit var manager: BleRelayManager
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
private var seq = 1
private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private var lastTheme: String? = null
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var relay1Button: Button private lateinit var relay1Button: Button
private lateinit var relay2Button: Button private lateinit var relay2Button: Button
private var lastTheme: String? = null
private val scanCallback = object : ScanCallback() { override fun onStatusChanged(status: BleStatus) {
override fun onScanResult(callbackType: Int, result: ScanResult) { when (status) {
val device = result.device BleStatus.Connected -> {
val address = device?.address ?: return statusText.text = getString(R.string.status_connected)
val name = device?.name ?: "" setupRelayButton()
Log.d(TAG, "Found device: $name ($address)") setupRelay2Button()
if (triedAddresses.contains(address)) { relay1Button.isEnabled = true
Log.d(TAG, "Skipping already-tried device $address") relay2Button.isEnabled = true
return
} }
stopScan() BleStatus.Disconnected -> {
connectToDevice(device) statusText.text = getString(R.string.status_connecting)
} relay1Button.isEnabled = false
} relay2Button.isEnabled = false
private val gattCallback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "GATT connection state change error: status=$status newState=$newState")
} }
if (newState == BluetoothProfile.STATE_CONNECTED) { BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning)
if (hmacKey == null) { BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting)
Log.w(TAG, "GATT connected but no key — disconnecting") BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key)
runOnUiThread { statusText.text = getString(R.string.status_no_key) } BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device)
gatt.disconnect() BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found)
return 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()
} }
Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
runOnUiThread { statusText.text = getString(R.string.status_discovering_services) }
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
runOnUiThread {
statusText.text = getString(R.string.status_connecting)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
seq = 1
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "Service discovery failed (status=$status)")
runOnUiThread { statusText.text = getString(R.string.status_service_error) }
return
}
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(256)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "MTU negotiation failed (status=$status)")
}
Log.d(TAG, "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(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
runOnUiThread { statusText.text = getString(R.string.status_service_not_found) }
}
} else {
Log.e(TAG, "Service not found")
runOnUiThread { statusText.text = getString(R.string.status_service_not_found) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: 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(TAG, "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)
}
runOnUiThread {
setupRelayButton()
setupRelay2Button()
statusText.text = getString(R.string.status_connected)
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
} else {
Log.w(TAG, "UUID mismatch — disconnecting")
triedAddresses.add(gatt.device.address)
runOnUiThread { statusText.text = getString(R.string.status_wrong_device) }
gatt.disconnect()
}
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d(TAG, "Write confirmed by local BLE stack (status=$status)")
} else {
Log.w(TAG, "Write failed (status=$status)")
runOnUiThread { statusText.text = getString(R.string.status_comm_error) }
} }
} }
} }
@ -198,6 +80,8 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
manager = BleRelayManager(this, this)
statusText = findViewById(R.id.statusText) statusText = findViewById(R.id.statusText)
relay1Button = findViewById(R.id.relay1Button) relay1Button = findViewById(R.id.relay1Button)
relay2Button = findViewById(R.id.relay2Button) relay2Button = findViewById(R.id.relay2Button)
@ -214,14 +98,10 @@ class MainActivity : AppCompatActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) { if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) {
stopScan() manager.disconnect()
bluetoothGatt?.close() manager.resetTriedAddresses()
bluetoothGatt = null manager.updateKeys()
relayCharacteristic = null handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
seq = 1
triedAddresses.clear()
loadState()
handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS)
} }
} }
@ -233,54 +113,19 @@ class MainActivity : AppCompatActivity() {
recreate() recreate()
return return
} }
loadState() if (!manager.hasPermissions()) {
if (hmacKey == null || deviceUuid == null) { requestPermissions()
statusText.text = getString(R.string.status_no_key) return
} else if (!isScanning && bluetoothGatt == null) { }
triedAddresses.clear() if (!manager.isConnected()) {
handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS) manager.resetTriedAddresses()
handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS)
} }
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
handler.removeCallbacks(scanRunnable) manager.stopScan()
stopScan()
}
private fun loadState() {
val prefs = PrefsHelper.getPrefs(this)
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)
Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}, UUID: ${if (deviceUuid != null) "${deviceUuid!!.size} bytes" else "none"}, seq=$seq")
}
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
} }
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
@ -288,11 +133,9 @@ class MainActivity : AppCompatActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) { if (requestCode == 1) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startScan() manager.startScan()
} else { } else {
statusText.text = getString(R.string.status_permissions_denied) onStatusChanged(BleStatus.PermissionsDenied)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
} }
} }
} }
@ -303,12 +146,12 @@ class MainActivity : AppCompatActivity() {
relay1Button.setOnTouchListener { _, event -> relay1Button.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
sendRelayCommand(0x01.toByte()) manager.sendRelayCommand(0x01.toByte())
relay1Button.text = getString(R.string.button_on) relay1Button.text = getString(R.string.button_on)
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(0x00.toByte()) manager.sendRelayCommand(0x00.toByte())
relay1Button.text = getString(R.string.button_hold) relay1Button.text = getString(R.string.button_hold)
true true
} }
@ -323,12 +166,12 @@ class MainActivity : AppCompatActivity() {
relay2Button.setOnTouchListener { _, event -> relay2Button.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
sendRelayCommand(0x02.toByte()) manager.sendRelayCommand(0x02.toByte())
relay2Button.text = getString(R.string.button_on) relay2Button.text = getString(R.string.button_on)
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(0x03.toByte()) manager.sendRelayCommand(0x03.toByte())
relay2Button.text = getString(R.string.button_hold) relay2Button.text = getString(R.string.button_hold)
true true
} }
@ -337,132 +180,14 @@ class MainActivity : AppCompatActivity() {
} }
} }
@SuppressLint("MissingPermission")
private fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
requestPermissions()
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
statusText.text = getString(R.string.status_bluetooth_disabled)
return
}
if (hmacKey == null || deviceUuid == null) {
statusText.text = getString(R.string.status_no_key)
return
}
isScanning = true
scanTimedOut = false
statusText.text = getString(R.string.status_scanning)
Log.d(TAG, "Starting BLE scan with service UUID filter")
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")
private 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++
Log.d(TAG, "Scan timeout, retry $scanTimeoutRetries/$MAX_SCAN_RETRIES")
statusText.text = getString(R.string.status_scanning)
handler.postDelayed(scanRunnable, 2000L)
return
}
statusText.text = getString(R.string.status_device_not_found)
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()
startScan()
}
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
runOnUiThread { statusText.text = getString(R.string.status_connecting) }
Log.d(TAG, "Connecting to ${device.address}")
bluetoothGatt = device.connectGatt(this, false, gattCallback)
}
@SuppressLint("MissingPermission")
private fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return
val packet = buildPacket(cmd, key)
char.value = packet
// WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was
// used, but the BLE write acknowledgement took long enough that the user could release
// the button before the ACK arrived. When ACTION_UP fired during a pending write, the
// release was silently ignored and the relay (and app) got stuck in the ON state.
// WRITE_TYPE_NO_RESPONSE removes that latency and allows immediate ON→OFF sequences.
// This means the UI reflects the user's intent ("pressed" / "released") rather than
// confirmed relay state — an acceptable trade-off for a hold-to-activate interaction.
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1
PrefsHelper.getPrefs(this).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})")
}
private 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)
}
}
private fun hasPermissions(): Boolean {
return getRequiredPermissions().all {
ActivityCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestPermissions() { private fun requestPermissions() {
ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1) val perms = manager.getRequiredPermissions()
ActivityCompat.requestPermissions(this, perms, 1)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
stopScan() manager.destroy()
bluetoothGatt?.close()
} }
} }

View file

@ -0,0 +1,776 @@
# 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<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**
```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<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**
```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<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**
```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

View file

@ -0,0 +1,172 @@
# BLE Refactor: BleRelayManager extractie
**Datum:** 2026-07-29
**Status:** goedgekeurd
## Doel
Extractie van alle BLE-logica uit `MainActivity.kt` naar een aparte `BleRelayManager` klasse. MainActivity wordt verantwoordelijk voor UI en lifecycle only.
## Scope
- **In:** BLE scan, GATT connectie, UUID verificatie, HMAC relay commands, key/seq management, auto-retry logica
- **Uit:** UI binding (views, touch listeners, string resources), lifecycle (onCreate/onResume/etc), permissions result handling, theme switching
- **Niet aangeraakt:** `PrefsHelper`, `SettingsActivity`, `EspFlasher`, `UsbSerialTransport`, firmware
## Nieuwe bestanden
### `BleStatus.kt` — enum + callback interface
```kotlin
enum class BleStatus {
Scanning,
Connecting,
Connected,
Disconnected,
NoKey,
WrongDevice,
ServiceNotFound,
ServiceError,
CommError,
DeviceNotFound,
BluetoothDisabled,
PermissionsDenied
}
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
}
```
### `BleRelayManager.kt` — publieke API
```kotlin
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
fun startScan()
fun stopScan()
fun sendRelayCommand(cmd: Byte)
fun disconnect()
fun updateKeys() // herlaadt keys na settings key exchange
fun resetTriedAddresses() // wist triedAddresses (bij onResume)
fun hasPermissions(): Boolean
fun getRequiredPermissions(): Array<String>
fun destroy() // cleanup handler, stop scan, close gatt
}
```
### Interne componenten (private)
1. **State:** `bluetoothGatt`, `relayCharacteristic`, `hmacKey`, `deviceUuid`, `seq`, `triedAddresses`, `isScanning`, `scanTimedOut`, `scanTimeoutRetries`
2. **Handler:** `Handler(Looper.getMainLooper())` voor scan scheduling en timeouts
3. **ScanCallback:** scan op `SERVICE_UUID`, skip `triedAddresses`, bij match → `stopScan()` + `connectToDevice()`
4. **GattCallback:** connectie → check key → discoverServices → requestMtu → read UUID char → verify → enable notifications. Bij disconnect: auto-retry na delay.
5. **HMAC:** `hexStringToBytes()`, `hmacSha256()`, `buildPacket()` — ongewijzigd verplaatst
6. **Key loading:** `loadState()` leest via `PrefsHelper.getPrefs(context)`
**Companion constants** (ongewijzigd): `SERVICE_UUID`, `CHARACTERISTIC_UUID`, `UUID_CHAR_UUID`, `CCCD_UUID`, `DEVICE_NAME`, `HMAC_PKT_SIZE`, `HMAC_LEN`, `SCAN_TIMEOUT_MS`, `RECONNECT_DELAY_MS`, `MAX_SCAN_RETRIES`
```mermaid
sequenceDiagram
participant MA as MainActivity
participant BRM as BleRelayManager
participant ESP as ESP32-S3
MA->>BRM: startScan()
BRM->>BRM: loadState() via PrefsHelper
BRM-->>MA: onStatusChanged(Scanning)
BRM->>ESP: BLE scan (filter SERVICE_UUID)
ESP-->>BRM: scan result
BRM->>BRM: skip triedAddresses?
BRM->>ESP: connectGatt()
BRM-->>MA: onStatusChanged(Connecting)
ESP-->>BRM: STATE_CONNECTED
BRM->>BRM: check hmacKey != null
BRM->>ESP: discoverServices → requestMtu(256)
BRM->>ESP: readCharacteristic(UUID_CHAR)
ESP-->>BRM: UUID value
BRM->>BRM: verify against stored deviceUuid
BRM->>ESP: setCharacteristicNotification + writeDescriptor(CCCD)
BRM-->>MA: onStatusChanged(Connected)
MA->>BRM: sendRelayCommand(0x01)
BRM->>BRM: buildPacket(cmd, hmacKey, seq)
BRM->>ESP: writeCharacteristic (NO_RESPONSE)
BRM->>Prefs: save seq
ESP-->>BRM: STATE_DISCONNECTED
BRM-->>MA: onStatusChanged(Disconnected)
BRM->>BRM: handler.postDelayed(startScan, 2s)
```
## MainActivity — wat blijft
| Component | Blijft in MainActivity |
|---|---|
| `onCreate` | View binding, settings button, `BleRelayManager` instantiatie |
| `onResume` | Theme check, `manager.resetTriedAddresses()`, `manager.startScan()` of toon NoKey |
| `onPause` | `manager.stopScan()` |
| `onDestroy` | `manager.destroy()` |
| `onActivityResult` | Bij RESULT_SCAN: `manager.disconnect()`, `manager.updateKeys()`, `manager.resetTriedAddresses()`, `manager.startScan()` |
| `onRequestPermissionsResult` | Bij granted: `manager.startScan()`; bij denied: UI update |
| `setupRelayButton()` | Touch listener → `manager.sendRelayCommand(...)` |
| `setupRelay2Button()` | Touch listener → `manager.sendRelayCommand(...)` |
| Status mapping | `onStatusChanged` → `statusText.text = getString(R.string.status_*)` + knop enabled/disabled |
### Status → UI mapping
```kotlin
callbacks = object : BleCallbacks {
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.DeviceNotFound -> {
// retry knop op relay1Button (huidig gedrag behouden)
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()
}
}
else -> statusText.text = getString(R.string.status_scanning) // fallback
}
}
}
```
## Risico's & mitigatie
| Risico | Mitigatie |
|---|---|
| Race condition bij snelle key-update + scan | `updateKeys()` stopt scan vóór herladen |
| Context leak via Handler | `destroy()` roept `handler.removeCallbacksAndMessages(null)` |
| GattCallback refs naar oude Activity | Manager instantieert per Activity, geen singleton |
| Touch listeners verwijzen naar verouderde manager | `setupRelay*Button()` opnieuw aanroepen bij statuswissel (huidig patroon) |
## Verificatie
1. Build: `./gradlew assembleDebug` moet slagen
2. Lint: `./gradlew lint` geen nieuwe warnings
3. Functioneel: APK deployen via ADB, BLE scan → connect → UUID verify → relay knoppen ON/OFF
4. Randgevallen: app herstarten (keys behouden), settings key exchange → rescan, timeout + retry, disconnect + auto-reconnect, permissie deny/accept