Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e5df0052 | ||
|
|
404f46acf8 | ||
|
|
0f3b695140 | ||
|
|
a60f6cdc1c | ||
|
|
92585455c8 | ||
|
|
82852e086e | ||
|
|
f41f1c3380 | ||
|
|
61c5c1de32 | ||
|
|
cd89604c9b | ||
|
|
459dc5f201 |
6 changed files with 1386 additions and 339 deletions
42
AGENTS.md
Normal file
42
AGENTS.md
Normal 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
|
||||||
|
```
|
||||||
311
android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
Normal file
311
android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt
Normal file
21
android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (hmacKey == null) {
|
|
||||||
Log.w(TAG, "GATT connected but no key — disconnecting")
|
|
||||||
runOnUiThread { statusText.text = getString(R.string.status_no_key) }
|
|
||||||
gatt.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
statusText.text = getString(R.string.status_connecting)
|
||||||
relay1Button.isEnabled = false
|
relay1Button.isEnabled = false
|
||||||
relay2Button.isEnabled = false
|
relay2Button.isEnabled = false
|
||||||
}
|
}
|
||||||
gatt.close()
|
BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning)
|
||||||
bluetoothGatt = null
|
BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting)
|
||||||
relayCharacteristic = null
|
BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key)
|
||||||
seq = 1
|
BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device)
|
||||||
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
|
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)
|
||||||
@SuppressLint("MissingPermission")
|
relay2Button.setOnTouchListener(null)
|
||||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
relay1Button.setOnClickListener(null)
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
relay2Button.setOnClickListener(null)
|
||||||
Log.w(TAG, "Service discovery failed (status=$status)")
|
relay1Button.text = getString(R.string.button_retry_scan)
|
||||||
runOnUiThread { statusText.text = getString(R.string.status_service_error) }
|
relay2Button.text = getString(R.string.button_hold)
|
||||||
return
|
relay2Button.isEnabled = false
|
||||||
}
|
relay1Button.isEnabled = true
|
||||||
Log.d(TAG, "Services discovered, requesting MTU...")
|
relay1Button.setOnClickListener {
|
||||||
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()
|
setupRelayButton()
|
||||||
setupRelay2Button()
|
setupRelay2Button()
|
||||||
statusText.text = getString(R.string.status_connected)
|
manager.startScan()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
776
docs/superpowers/plans/2026-07-29-ble-refactor.md
Normal file
776
docs/superpowers/plans/2026-07-29-ble-refactor.md
Normal 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
|
||||||
172
docs/superpowers/specs/2026-07-29-ble-refactor-design.md
Normal file
172
docs/superpowers/specs/2026-07-29-ble-refactor-design.md
Normal 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
|
||||||
Loading…
Reference in a new issue