diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f07152f..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 15fdeb1..0000000 --- a/android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt +++ /dev/null @@ -1,311 +0,0 @@ -// 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 deleted file mode 100644 index 19a0046..0000000 --- a/android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt +++ /dev/null @@ -1,21 +0,0 @@ -// 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 ed7e259..08321f0 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt @@ -1,75 +1,193 @@ // 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(), BleCallbacks { +class MainActivity : AppCompatActivity() { 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 lateinit var manager: BleRelayManager + 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 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 - override fun onStatusChanged(status: BleStatus) { - when (status) { - BleStatus.Connected -> { - statusText.text = getString(R.string.status_connected) - setupRelayButton() - setupRelay2Button() - relay1Button.isEnabled = true - relay2Button.isEnabled = true + 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 } - BleStatus.Disconnected -> { - statusText.text = getString(R.string.status_connecting) - relay1Button.isEnabled = false - relay2Button.isEnabled = false + 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.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() + 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) + 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) } } } } @@ -80,8 +198,6 @@ class MainActivity : AppCompatActivity(), BleCallbacks { 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) @@ -98,10 +214,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks { 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) + stopScan() + bluetoothGatt?.close() + bluetoothGatt = null + relayCharacteristic = null + seq = 1 + triedAddresses.clear() + loadState() + handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS) } } @@ -113,19 +233,54 @@ class MainActivity : AppCompatActivity(), BleCallbacks { recreate() return } - if (!manager.hasPermissions()) { - requestPermissions() - return - } - if (!manager.isConnected()) { - manager.resetTriedAddresses() - handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS) + 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) } } override fun onPause() { super.onPause() - manager.stopScan() + 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 } @Deprecated("Deprecated in Java") @@ -133,9 +288,11 @@ class MainActivity : AppCompatActivity(), BleCallbacks { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == 1) { if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { - manager.startScan() + startScan() } else { - onStatusChanged(BleStatus.PermissionsDenied) + statusText.text = getString(R.string.status_permissions_denied) + relay1Button.isEnabled = false + relay2Button.isEnabled = false } } } @@ -146,12 +303,12 @@ class MainActivity : AppCompatActivity(), BleCallbacks { relay1Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { - manager.sendRelayCommand(0x01.toByte()) + sendRelayCommand(0x01.toByte()) relay1Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - manager.sendRelayCommand(0x00.toByte()) + sendRelayCommand(0x00.toByte()) relay1Button.text = getString(R.string.button_hold) true } @@ -166,12 +323,12 @@ class MainActivity : AppCompatActivity(), BleCallbacks { relay2Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { - manager.sendRelayCommand(0x02.toByte()) + sendRelayCommand(0x02.toByte()) relay2Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - manager.sendRelayCommand(0x03.toByte()) + sendRelayCommand(0x03.toByte()) relay2Button.text = getString(R.string.button_hold) true } @@ -180,14 +337,132 @@ class MainActivity : AppCompatActivity(), BleCallbacks { } } + @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() { - val perms = manager.getRequiredPermissions() - ActivityCompat.requestPermissions(this, perms, 1) + ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1) } override fun onDestroy() { super.onDestroy() handler.removeCallbacksAndMessages(null) - manager.destroy() + stopScan() + bluetoothGatt?.close() } -} +} \ 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 deleted file mode 100644 index 235b929..0000000 --- a/docs/superpowers/plans/2026-07-29-ble-refactor.md +++ /dev/null @@ -1,776 +0,0 @@ -# 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 deleted file mode 100644 index b046529..0000000 --- a/docs/superpowers/specs/2026-07-29-ble-refactor-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# 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