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