From f71da768b19b7010f459af7677ec94c9ae846e8b Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 28 Nov 2025 20:35:03 +0100 Subject: [PATCH 01/17] Add custom Capacitor Bluetooth plugin --- android/app/src/main/AndroidManifest.xml | 21 + .../betaflight/configurator/MainActivity.java | 2 + .../bluetooth/BetaflightBluetoothPlugin.java | 996 ++++++++++++++++++ src/js/protocols/CapacitorBluetooth.js | 489 +++++++++ src/js/protocols/devices.js | 6 + src/js/serial.js | 6 +- src/js/utils/checkCompatibility.js | 2 +- 7 files changed, 1520 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java create mode 100644 src/js/protocols/CapacitorBluetooth.js diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 77bd517baf9..e9d4ef15b1e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -55,6 +55,27 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/betaflight/configurator/MainActivity.java b/android/app/src/main/java/betaflight/configurator/MainActivity.java index 4671548f807..2c846875aa0 100644 --- a/android/app/src/main/java/betaflight/configurator/MainActivity.java +++ b/android/app/src/main/java/betaflight/configurator/MainActivity.java @@ -2,6 +2,7 @@ import android.os.Bundle; +import betaflight.configurator.protocols.bluetooth.BetaflightBluetoothPlugin; import betaflight.configurator.protocols.serial.BetaflightSerialPlugin; import com.getcapacitor.BridgeActivity; @@ -9,6 +10,7 @@ public class MainActivity extends BridgeActivity { @Override protected void onCreate(Bundle savedInstanceState) { registerPlugin(BetaflightSerialPlugin.class); + registerPlugin(BetaflightBluetoothPlugin.class); super.onCreate(savedInstanceState); } } diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java new file mode 100644 index 00000000000..94822da7e82 --- /dev/null +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -0,0 +1,996 @@ +package betaflight.configurator.protocols.bluetooth; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelUuid; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.PermissionState; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.annotation.PermissionCallback; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Custom Capacitor plugin that provides BLE scanning, connection management, and + * MSP-friendly binary transport for Betaflight Configurator. + */ +@CapacitorPlugin( + name = "BetaflightBluetooth", + permissions = { + @Permission( + alias = "bluetooth", + strings = { + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_FINE_LOCATION + } + ) + } +) +public class BetaflightBluetoothPlugin extends Plugin { + private static final String TAG = "BetaflightBluetooth"; + private static final long DEFAULT_SCAN_TIMEOUT_MS = 15_000L; + private static final long DEFAULT_CONNECT_TIMEOUT_MS = 12_000L; + private static final UUID CLIENT_CONFIG_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + private enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING + } + + private static final class DiscoveredDevice { + final BluetoothDevice device; + final ScanResult scanResult; + + DiscoveredDevice(BluetoothDevice device, ScanResult scanResult) { + this.device = device; + this.scanResult = scanResult; + } + } + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final AtomicReference connectionState = new AtomicReference<>(ConnectionState.DISCONNECTED); + private final Map discoveredDevices = new ConcurrentHashMap<>(); + private final Map activeNotifications = new ConcurrentHashMap<>(); + + private BluetoothAdapter bluetoothAdapter; + private BluetoothLeScanner bluetoothLeScanner; + private ScanCallback activeScanCallback; + private Runnable scanTimeoutRunnable; + private PluginCall pendingScanCall; + + private BluetoothGatt bluetoothGatt; + private Runnable connectTimeoutRunnable; + private PluginCall pendingConnectCall; + private String connectedDeviceId; + private final List pendingStartNotificationCalls = new ArrayList<>(); + private volatile boolean servicesDiscovered = false; + + @Override + public void load() { + super.load(); + BluetoothManager manager = (BluetoothManager) getContext().getSystemService(Context.BLUETOOTH_SERVICE); + if (manager != null) { + bluetoothAdapter = manager.getAdapter(); + if (bluetoothAdapter != null) { + bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner(); + } + } + Log.d(TAG, "BetaflightBluetooth plugin loaded"); + } + + @Override + protected void handleOnDestroy() { + stopScanInternal(); + disconnectInternal(true); + super.handleOnDestroy(); + } + + @PluginMethod + public void checkStatus(PluginCall call) { + JSObject result = new JSObject(); + result.put("available", bluetoothAdapter != null); + result.put("enabled", bluetoothAdapter != null && bluetoothAdapter.isEnabled()); + result.put("connected", connectionState.get() == ConnectionState.CONNECTED); + result.put("deviceId", connectedDeviceId); + call.resolve(result); + } + + @PluginMethod + public void requestPermissions(PluginCall call) { + if (getPermissionState("bluetooth") == PermissionState.GRANTED) { + JSObject result = new JSObject(); + result.put("granted", true); + call.resolve(result); + return; + } + requestPermissionForAlias("bluetooth", call, "onPermissionResult"); + } + + @PermissionCallback + private void onPermissionResult(PluginCall call) { + boolean granted = getPermissionState("bluetooth") == PermissionState.GRANTED; + if (granted) { + JSObject result = new JSObject(); + result.put("granted", true); + call.resolve(result); + } else { + call.reject("Bluetooth permissions denied"); + } + } + + @PluginMethod + public void getDevices(PluginCall call) { + JSArray devices = new JSArray(); + for (DiscoveredDevice entry : discoveredDevices.values()) { + devices.put(createDevicePayload(entry)); + } + JSObject result = new JSObject(); + result.put("devices", devices); + call.resolve(result); + } + + @PluginMethod + public void requestDevice(PluginCall call) { + if (!ensureBluetoothReady(call)) { + return; + } + if (pendingScanCall != null) { + call.reject("Another scan is already running"); + return; + } + + List serviceFilter = parseUuidArray(call.getArray("services")); + List optionalServices = parseUuidArray(call.getArray("optionalServices")); + boolean acceptAll = call.getBoolean("acceptAllDevices", false); + String nameFilter = call.getString("name"); + String prefixFilter = call.getString("namePrefix"); + long timeout = call.getLong("timeout", DEFAULT_SCAN_TIMEOUT_MS); + + List combinedFilter = new ArrayList<>(serviceFilter); + if (!optionalServices.isEmpty()) { + combinedFilter.addAll(optionalServices); + } + + pendingScanCall = call; + call.setKeepAlive(true); + + startLeScan(new ScanCriteria(acceptAll, nameFilter, prefixFilter, combinedFilter), timeout); + } + + @PluginMethod + public void stopScan(PluginCall call) { + stopScanInternal(); + JSObject result = new JSObject(); + result.put("stopped", true); + call.resolve(result); + } + + @PluginMethod + public void connect(PluginCall call) { + if (!ensureBluetoothReady(call)) { + return; + } + + String deviceId = call.getString("deviceId"); + if (TextUtils.isEmpty(deviceId)) { + call.reject("deviceId is required"); + return; + } + + if (!connectionState.compareAndSet(ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) { + call.reject("Another connection is active"); + return; + } + + BluetoothDevice device = resolveDevice(deviceId); + if (device == null) { + connectionState.set(ConnectionState.DISCONNECTED); + call.reject("Device not found: " + deviceId); + return; + } + + rejectPendingStartNotifications("Connection reset"); + pendingStartNotificationCalls.clear(); + servicesDiscovered = false; + + pendingConnectCall = call; + call.setKeepAlive(true); + connectedDeviceId = deviceId; + + runOnMainThread(() -> openGatt(device)); + } + + @PluginMethod + public void disconnect(PluginCall call) { + disconnectInternal(false); + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + } + + @PluginMethod + public void write(PluginCall call) { + if (!ensureConnected(call)) { + return; + } + + String serviceId = call.getString("service"); + String characteristicId = call.getString("characteristic"); + String value = call.getString("value", call.getString("data")); + String encoding = call.getString("encoding", "base64"); + boolean withoutResponse = call.getBoolean("withoutResponse", false); + + if (TextUtils.isEmpty(serviceId) || TextUtils.isEmpty(characteristicId)) { + call.reject("service and characteristic are required"); + return; + } + if (TextUtils.isEmpty(value)) { + call.reject("value is required"); + return; + } + + UUID serviceUuid = parseUuid(serviceId); + UUID characteristicUuid = parseUuid(characteristicId); + if (serviceUuid == null || characteristicUuid == null) { + call.reject("Invalid UUID format"); + return; + } + + BluetoothGatt gatt = bluetoothGatt; + if (gatt == null) { + call.reject("Not connected"); + return; + } + + BluetoothGattService service = gatt.getService(serviceUuid); + BluetoothGattCharacteristic characteristic = service != null ? service.getCharacteristic(characteristicUuid) : null; + if (characteristic == null) { + BluetoothGattService fallback = findServiceContainingCharacteristic(gatt, characteristicUuid); + if (fallback != null) { + service = fallback; + characteristic = fallback.getCharacteristic(characteristicUuid); + Log.w(TAG, "Requested service " + serviceId + " not found, using " + service.getUuid()); + } + } + if (characteristic == null) { + logGattLayout("Characteristic lookup failure", gatt); + call.reject("Characteristic not found"); + return; + } + + byte[] payload; + try { + payload = decodePayload(value, encoding); + } catch (IllegalArgumentException ex) { + call.reject("Failed to decode payload: " + ex.getMessage()); + return; + } + + int writeType = withoutResponse ? BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE : BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; + + boolean submitted = submitWrite(gatt, characteristic, payload, writeType); + if (!submitted) { + call.reject("Unable to write characteristic"); + return; + } + + JSObject result = new JSObject(); + result.put("bytesSent", payload.length); + call.resolve(result); + } + + @PluginMethod + public void startNotifications(PluginCall call) { + if (!ensureConnected(call)) { + return; + } + + if (!servicesDiscovered) { + queueStartNotificationCall(call); + return; + } + + startNotificationsInternal(call); + } + + private void startNotificationsInternal(PluginCall call) { + String serviceId = call.getString("service"); + String characteristicId = call.getString("characteristic"); + if (TextUtils.isEmpty(serviceId) || TextUtils.isEmpty(characteristicId)) { + call.setKeepAlive(false); + call.reject("service and characteristic are required"); + return; + } + + UUID serviceUuid = parseUuid(serviceId); + UUID characteristicUuid = parseUuid(characteristicId); + if (serviceUuid == null || characteristicUuid == null) { + call.setKeepAlive(false); + call.reject("Invalid UUID format"); + return; + } + + BluetoothGatt gatt = bluetoothGatt; + if (gatt == null) { + call.setKeepAlive(false); + call.reject("Not connected"); + return; + } + + BluetoothGattService service = gatt.getService(serviceUuid); + BluetoothGattCharacteristic characteristic = service != null ? service.getCharacteristic(characteristicUuid) : null; + if (characteristic == null) { + BluetoothGattService fallback = findServiceContainingCharacteristic(gatt, characteristicUuid); + if (fallback != null) { + service = fallback; + characteristic = fallback.getCharacteristic(characteristicUuid); + Log.w(TAG, "Requested service " + serviceId + " not found, using " + service.getUuid()); + } + } + if (characteristic == null) { + logGattLayout("Characteristic lookup failure", gatt); + call.setKeepAlive(false); + call.reject("Characteristic not found"); + return; + } + + if (!enableNotifications(gatt, characteristic, true)) { + call.setKeepAlive(false); + call.reject("Failed to enable notifications"); + return; + } + + activeNotifications.put(notificationKey(serviceUuid, characteristicUuid), characteristic); + + JSObject result = new JSObject(); + result.put("started", true); + call.setKeepAlive(false); + call.resolve(result); + } + + @PluginMethod + public void stopNotifications(PluginCall call) { + if (!ensureConnected(call)) { + return; + } + + UUID serviceUuid = parseUuid(call.getString("service")); + UUID characteristicUuid = parseUuid(call.getString("characteristic")); + if (serviceUuid == null || characteristicUuid == null) { + call.reject("Invalid UUID format"); + return; + } + + BluetoothGattCharacteristic characteristic = activeNotifications.remove(notificationKey(serviceUuid, characteristicUuid)); + if (characteristic == null) { + characteristic = removeNotificationByCharacteristic(characteristicUuid); + } + if (characteristic == null) { + JSObject result = new JSObject(); + result.put("stopped", true); + call.resolve(result); + return; + } + + BluetoothGatt gatt = bluetoothGatt; + if (gatt != null) { + enableNotifications(gatt, characteristic, false); + } + + JSObject result = new JSObject(); + result.put("stopped", true); + call.resolve(result); + } + + private void startLeScan(ScanCriteria criteria, long timeoutMs) { + if (bluetoothAdapter == null) { + rejectPendingScan("Bluetooth adapter unavailable"); + return; + } + bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner(); + if (bluetoothLeScanner == null) { + rejectPendingScan("Bluetooth LE scanner unavailable"); + return; + } + + List filters = buildScanFilters(criteria.serviceUuids); + ScanSettings settings = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build(); + + activeScanCallback = new ScanCallback() { + @Override + public void onScanResult(int callbackType, ScanResult result) { + handleScanResult(result, criteria); + } + + @Override + public void onBatchScanResults(List results) { + for (ScanResult result : results) { + handleScanResult(result, criteria); + } + } + + @Override + public void onScanFailed(int errorCode) { + rejectPendingScan("Scan failed: " + errorCode); + } + }; + + bluetoothLeScanner.startScan(filters.isEmpty() ? null : filters, settings, activeScanCallback); + + scanTimeoutRunnable = () -> rejectPendingScan("Scan timed out"); + mainHandler.postDelayed(scanTimeoutRunnable, timeoutMs); + } + + private void handleScanResult(ScanResult result, ScanCriteria criteria) { + if (result == null || result.getDevice() == null) { + return; + } + + if (!criteria.matches(result)) { + return; + } + + BluetoothDevice device = result.getDevice(); + String deviceId = safeDeviceId(device); + + if (!TextUtils.isEmpty(deviceId)) { + discoveredDevices.put(deviceId, new DiscoveredDevice(device, result)); + } + + JSObject payload = createDevicePayload(new DiscoveredDevice(device, result)); + notifyListeners("deviceDiscovered", payload); + + PluginCall call = pendingScanCall; + if (call != null) { + stopScanInternal(); + JSObject resultPayload = new JSObject(); + resultPayload.put("device", payload); + call.setKeepAlive(false); + call.resolve(resultPayload); + } + } + + private void stopScanInternal() { + if (bluetoothLeScanner != null && activeScanCallback != null) { + bluetoothLeScanner.stopScan(activeScanCallback); + } + activeScanCallback = null; + if (scanTimeoutRunnable != null) { + mainHandler.removeCallbacks(scanTimeoutRunnable); + scanTimeoutRunnable = null; + } + if (pendingScanCall != null) { + pendingScanCall.setKeepAlive(false); + pendingScanCall = null; + } + } + + @SuppressLint("MissingPermission") + private void openGatt(BluetoothDevice device) { + if (device == null) { + failConnect("Device is null"); + return; + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + bluetoothGatt = device.connectGatt(getContext(), false, gattCallback, BluetoothDevice.TRANSPORT_LE); + } else { + bluetoothGatt = device.connectGatt(getContext(), false, gattCallback); + } + } catch (SecurityException ex) { + failConnect("Missing BLUETOOTH_CONNECT permission"); + return; + } + + if (bluetoothGatt == null) { + failConnect("Unable to open GATT connection"); + return; + } + + connectTimeoutRunnable = () -> failConnect("Connection timed out"); + mainHandler.postDelayed(connectTimeoutRunnable, DEFAULT_CONNECT_TIMEOUT_MS); + } + + private void failConnect(String reason) { + Log.e(TAG, "Connection failed: " + reason); + connectionState.set(ConnectionState.DISCONNECTED); + String failingDeviceId = connectedDeviceId; + if (connectTimeoutRunnable != null) { + mainHandler.removeCallbacks(connectTimeoutRunnable); + connectTimeoutRunnable = null; + } + cleanupGatt(); + + PluginCall call = pendingConnectCall; + if (call != null) { + pendingConnectCall = null; + call.setKeepAlive(false); + call.reject(reason); + } + + notifyConnectionState(false, reason, failingDeviceId); + rejectPendingStartNotifications(reason); + } + + private void connectedSuccessfully() { + if (connectTimeoutRunnable != null) { + mainHandler.removeCallbacks(connectTimeoutRunnable); + connectTimeoutRunnable = null; + } + + PluginCall call = pendingConnectCall; + if (call != null) { + pendingConnectCall = null; + JSObject result = new JSObject(); + result.put("connected", true); + call.setKeepAlive(false); + call.resolve(result); + } + + notifyConnectionState(true, "connected", connectedDeviceId); + } + + private void disconnectInternal(boolean fromDestroy) { + ConnectionState current = connectionState.getAndSet(fromDestroy ? ConnectionState.DISCONNECTED : ConnectionState.DISCONNECTING); + if (current == ConnectionState.DISCONNECTED && !fromDestroy) { + cleanupGatt(); + return; + } + + if (connectTimeoutRunnable != null) { + mainHandler.removeCallbacks(connectTimeoutRunnable); + connectTimeoutRunnable = null; + } + + String lastDeviceId = connectedDeviceId; + cleanupGatt(); + activeNotifications.clear(); + connectionState.set(ConnectionState.DISCONNECTED); + notifyConnectionState(false, "disconnected", lastDeviceId); + rejectPendingStartNotifications("Device disconnected"); + } + + @SuppressLint("MissingPermission") + private void cleanupGatt() { + if (bluetoothGatt != null) { + try { + bluetoothGatt.disconnect(); + } catch (Exception ignored) {} + try { + bluetoothGatt.close(); + } catch (Exception ignored) {} + } + bluetoothGatt = null; + connectedDeviceId = null; + servicesDiscovered = false; + } + + private void notifyConnectionState(boolean connected, String reason, String deviceId) { + JSObject payload = new JSObject(); + payload.put("connected", connected); + payload.put("deviceId", deviceId); + payload.put("state", connectionState.get().name()); + payload.put("reason", reason); + notifyListeners("connectionState", payload); + } + + private boolean ensureBluetoothReady(PluginCall call) { + if (getPermissionState("bluetooth") != PermissionState.GRANTED) { + call.reject("Bluetooth permission not granted"); + return false; + } + if (bluetoothAdapter == null) { + call.reject("Bluetooth adapter unavailable"); + return false; + } + if (!bluetoothAdapter.isEnabled()) { + call.reject("Bluetooth adapter disabled"); + return false; + } + return true; + } + + private boolean ensureConnected(PluginCall call) { + if (!ensureBluetoothReady(call)) { + return false; + } + if (connectionState.get() != ConnectionState.CONNECTED || bluetoothGatt == null) { + call.reject("Not connected to any device"); + return false; + } + return true; + } + + private BluetoothDevice resolveDevice(String deviceId) { + if (TextUtils.isEmpty(deviceId)) { + return null; + } + DiscoveredDevice cached = discoveredDevices.get(deviceId); + if (cached != null) { + return cached.device; + } + if (bluetoothAdapter == null) { + return null; + } + try { + return bluetoothAdapter.getRemoteDevice(deviceId); + } catch (IllegalArgumentException ex) { + Log.e(TAG, "Invalid device address", ex); + return null; + } + } + + private JSObject createDevicePayload(DiscoveredDevice entry) { + JSObject device = new JSObject(); + BluetoothDevice btDevice = entry.device; + device.put("deviceId", safeDeviceId(btDevice)); + device.put("name", btDevice != null ? btDevice.getName() : null); + device.put("bondState", btDevice != null ? btDevice.getBondState() : BluetoothDevice.BOND_NONE); + + ScanResult scanResult = entry.scanResult; + if (scanResult != null) { + device.put("rssi", scanResult.getRssi()); + JSArray uuids = new JSArray(); + ScanRecord record = scanResult.getScanRecord(); + if (record != null && record.getServiceUuids() != null) { + for (ParcelUuid uuid : record.getServiceUuids()) { + uuids.put(uuid.getUuid().toString()); + } + } + device.put("uuids", uuids); + } + + return device; + } + + private void rejectPendingScan(String message) { + Log.w(TAG, message); + PluginCall call = pendingScanCall; + if (call != null) { + pendingScanCall = null; + call.setKeepAlive(false); + call.reject(message); + } + stopScanInternal(); + } + + private void runOnMainThread(Runnable runnable) { + if (Looper.myLooper() == Looper.getMainLooper()) { + runnable.run(); + } else { + mainHandler.post(runnable); + } + } + + private boolean enableNotifications(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, boolean enable) { + try { + if (!gatt.setCharacteristicNotification(characteristic, enable)) { + return false; + } + + BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CLIENT_CONFIG_DESCRIPTOR); + if (descriptor != null) { + byte[] value = enable + ? ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 + ? BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + : BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; + + descriptor.setValue(value); + gatt.writeDescriptor(descriptor); + } + return true; + } catch (SecurityException ex) { + Log.e(TAG, "Notification permission issue", ex); + return false; + } + } + + private boolean submitWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] payload, int writeType) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return gatt.writeCharacteristic(characteristic, payload, writeType) == BluetoothGatt.GATT_SUCCESS; + } + characteristic.setWriteType(writeType); + characteristic.setValue(payload); + return gatt.writeCharacteristic(characteristic); + } catch (SecurityException ex) { + Log.e(TAG, "Write failed due to permissions", ex); + return false; + } + } + + private byte[] decodePayload(String value, String encoding) { + if ("hex".equalsIgnoreCase(encoding)) { + return hexToBytes(value); + } + if ("utf8".equalsIgnoreCase(encoding) || "utf-8".equalsIgnoreCase(encoding)) { + return value.getBytes(StandardCharsets.UTF_8); + } + return Base64.decode(value, Base64.DEFAULT); + } + + private BluetoothGattService findServiceContainingCharacteristic(BluetoothGatt gatt, UUID characteristicUuid) { + if (gatt == null || characteristicUuid == null) { + return null; + } + for (BluetoothGattService candidate : gatt.getServices()) { + if (candidate.getCharacteristic(characteristicUuid) != null) { + return candidate; + } + } + return null; + } + + private void logGattLayout(String reason, BluetoothGatt gatt) { + if (gatt == null) { + return; + } + StringBuilder builder = new StringBuilder() + .append(reason == null ? "GATT layout" : reason) + .append(" (device=") + .append(connectedDeviceId) + .append(")\n"); + for (BluetoothGattService service : gatt.getServices()) { + builder.append(" Service ").append(service.getUuid()).append('\n'); + for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) { + builder + .append(" Characteristic ") + .append(characteristic.getUuid()) + .append(" props=0x") + .append(Integer.toHexString(characteristic.getProperties())) + .append('\n'); + } + } + Log.i(TAG, builder.toString()); + } + + private BluetoothGattCharacteristic removeNotificationByCharacteristic(UUID characteristicUuid) { + if (characteristicUuid == null) { + return null; + } + Iterator> iterator = activeNotifications.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + BluetoothGattCharacteristic candidate = entry.getValue(); + if (candidate != null && characteristicUuid.equals(candidate.getUuid())) { + iterator.remove(); + return candidate; + } + } + return null; + } + + private void queueStartNotificationCall(PluginCall call) { + call.setKeepAlive(true); + pendingStartNotificationCalls.add(call); + Log.d(TAG, "Queued startNotifications until services are discovered"); + } + + private void flushPendingStartNotificationCalls() { + if (!servicesDiscovered || pendingStartNotificationCalls.isEmpty()) { + return; + } + List queued = new ArrayList<>(pendingStartNotificationCalls); + pendingStartNotificationCalls.clear(); + for (PluginCall pendingCall : queued) { + startNotificationsInternal(pendingCall); + } + } + + private void rejectPendingStartNotifications(String reason) { + if (pendingStartNotificationCalls.isEmpty()) { + return; + } + List queued = new ArrayList<>(pendingStartNotificationCalls); + pendingStartNotificationCalls.clear(); + for (PluginCall pendingCall : queued) { + pendingCall.setKeepAlive(false); + pendingCall.reject(reason); + } + } + + private byte[] hexToBytes(String hex) { + String cleaned = hex.replace(" ", ""); + if ((cleaned.length() & 1) != 0) { + throw new IllegalArgumentException("Hex payload must have even length"); + } + byte[] data = new byte[cleaned.length() / 2]; + for (int i = 0; i < cleaned.length(); i += 2) { + int hi = Character.digit(cleaned.charAt(i), 16); + int lo = Character.digit(cleaned.charAt(i + 1), 16); + if (hi < 0 || lo < 0) { + throw new IllegalArgumentException("Invalid hex digit"); + } + data[i / 2] = (byte) ((hi << 4) + lo); + } + return data; + } + + private List parseUuidArray(JSArray array) { + if (array == null) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + try { + String value = array.getString(i); + UUID uuid = parseUuid(value); + if (uuid != null) { + result.add(uuid); + } + } catch (Exception ignored) {} + } + return result; + } + + private UUID parseUuid(String value) { + if (TextUtils.isEmpty(value)) { + return null; + } + try { + return UUID.fromString(value.toLowerCase(Locale.US)); + } catch (IllegalArgumentException ex) { + Log.e(TAG, "Invalid UUID: " + value, ex); + return null; + } + } + + private String notificationKey(UUID service, UUID characteristic) { + return service.toString() + "#" + characteristic.toString(); + } + + private List buildScanFilters(List uuids) { + if (uuids == null || uuids.isEmpty()) { + return Collections.emptyList(); + } + List filters = new ArrayList<>(); + for (UUID uuid : uuids) { + filters.add(new ScanFilter.Builder().setServiceUuid(new ParcelUuid(uuid)).build()); + } + return filters; + } + + private String safeDeviceId(BluetoothDevice device) { + return device != null ? device.getAddress() : null; + } + + private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + connectionState.set(ConnectionState.CONNECTED); + gatt.discoverServices(); + connectedSuccessfully(); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + connectionState.set(ConnectionState.DISCONNECTED); + cleanupGatt(); + failConnect(status == BluetoothGatt.GATT_SUCCESS ? "Disconnected" : "Connect status: " + status); + } + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + return; + } + servicesDiscovered = true; + flushPendingStartNotificationCalls(); + logGattLayout("Services discovered", gatt); + JSArray services = new JSArray(); + for (BluetoothGattService service : gatt.getServices()) { + services.put(service.getUuid().toString()); + } + JSObject payload = new JSObject(); + payload.put("deviceId", connectedDeviceId); + payload.put("services", services); + notifyListeners("services", payload); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { + JSObject payload = new JSObject(); + payload.put("deviceId", connectedDeviceId); + payload.put("service", characteristic.getService().getUuid().toString()); + payload.put("characteristic", characteristic.getUuid().toString()); + payload.put("value", Base64.encodeToString(value, Base64.NO_WRAP)); + notifyListeners("notification", payload); + } + }; + + private static final class ScanCriteria { + final boolean acceptAll; + final String name; + final String prefix; + final List serviceUuids; + + ScanCriteria(boolean acceptAll, String name, String prefix, List serviceUuids) { + this.acceptAll = acceptAll; + this.name = name; + this.prefix = prefix; + this.serviceUuids = serviceUuids != null ? serviceUuids : Collections.emptyList(); + } + + boolean matches(ScanResult result) { + if (acceptAll) { + return true; + } + + BluetoothDevice device = result.getDevice(); + String deviceName = device != null ? device.getName() : null; + + if (!TextUtils.isEmpty(name) && !name.equals(deviceName)) { + return false; + } + + if (!TextUtils.isEmpty(prefix)) { + if (TextUtils.isEmpty(deviceName) || !deviceName.toLowerCase(Locale.US).startsWith(prefix.toLowerCase(Locale.US))) { + return false; + } + } + + if (serviceUuids.isEmpty()) { + return true; + } + + ScanRecord record = result.getScanRecord(); + List advertised = record != null ? record.getServiceUuids() : null; + if (advertised == null) { + return false; + } + + for (ParcelUuid parcelUuid : advertised) { + if (serviceUuids.contains(parcelUuid.getUuid())) { + return true; + } + } + + return false; + } + } +} diff --git a/src/js/protocols/CapacitorBluetooth.js b/src/js/protocols/CapacitorBluetooth.js new file mode 100644 index 00000000000..2536a7d8d7c --- /dev/null +++ b/src/js/protocols/CapacitorBluetooth.js @@ -0,0 +1,489 @@ +import { i18n } from "../localization"; +import { gui_log } from "../gui_log"; +import { bluetoothDevices } from "./devices"; +import { Capacitor } from "@capacitor/core"; + +const BetaflightBluetooth = Capacitor.Plugins.BetaflightBluetooth; + +const logHead = "[CAPACITOR-BLUETOOTH]"; + +const toLowerUuid = (uuid) => uuid?.toLowerCase?.() ?? uuid; + +const toUint8Array = (data) => { + if (data instanceof Uint8Array) { + return data; + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (Array.isArray(data)) { + return Uint8Array.from(data); + } + throw new TypeError("Unsupported data type for BLE write operation"); +}; + +const toBase64 = (buffer) => { + if (typeof Buffer !== "undefined") { + return Buffer.from(buffer).toString("base64"); + } + let binary = ""; + for (let idx = 0; idx < buffer.length; idx += 1) { + binary += String.fromCharCode(buffer[idx]); + } + return btoa(binary); +}; + +const fromBase64 = (value) => { + if (!value) { + return null; + } + if (typeof Buffer !== "undefined") { + return Uint8Array.from(Buffer.from(value, "base64")); + } + const binary = atob(value); + const buffer = new Uint8Array(binary.length); + for (let idx = 0; idx < binary.length; idx += 1) { + buffer[idx] = binary.charCodeAt(idx); + } + return buffer; +}; + +class CapacitorBluetooth extends EventTarget { + constructor() { + super(); + + this.connected = false; + this.openRequested = false; + this.openCanceled = false; + this.closeRequested = false; + this.transmitting = false; + this.connectionInfo = null; + this.lastWrite = null; + + this.bitrate = 0; + this.bytesSent = 0; + this.bytesReceived = 0; + this.failed = 0; + this.message_checksum = 0; + + this.devices = []; + this.device = null; + + this.logHead = logHead; + + this.bt11_crc_corruption_logged = false; + + this.writeQueue = Promise.resolve(); + this.bleInitialized = false; + this.notificationActive = false; + this.disconnectHandled = true; + this.nativeListeners = []; + this.nativeListenersReady = false; + + this.handleNotification = this.handleNotification.bind(this); + this.handleRemoteDisconnect = this.handleRemoteDisconnect.bind(this); + + this.attachNativeListeners(); + } + + handleNewDevice(devicePayload) { + const resolvedDevice = devicePayload?.device ?? devicePayload; + if (!resolvedDevice?.deviceId) { + console.warn(`${this.logHead} Ignoring device without an ID`, devicePayload); + return null; + } + + const existing = this.devices.find((dev) => dev.device?.deviceId === resolvedDevice.deviceId); + if (existing) { + existing.device = resolvedDevice; + return existing; + } + + const added = this.createPort(resolvedDevice); + this.devices.push(added); + this.dispatchEvent(new CustomEvent("addedDevice", { detail: added })); + return added; + } + + handleRemovedDevice(deviceId) { + const removed = this.devices.find((device) => device.device?.deviceId === deviceId); + if (!removed) { + return; + } + this.devices = this.devices.filter((device) => device.device?.deviceId !== deviceId); + this.dispatchEvent(new CustomEvent("removedDevice", { detail: removed })); + } + + getConnectedPort() { + return this.device; + } + + createPort(device) { + return { + path: "bluetooth", + displayName: device.name, + vendorId: "unknown", + productId: device.deviceId, + device: device, + }; + } + + isBT11CorruptionPattern(expectedChecksum) { + if (expectedChecksum !== 0xff || this.message_checksum === 0xff) { + return false; + } + + if (!this.connected) { + return false; + } + + const deviceDescription = this.deviceDescription; + if (!deviceDescription) { + return false; + } + + return deviceDescription?.susceptibleToCrcCorruption ?? false; + } + + shouldBypassCrc(expectedChecksum) { + // Special handling for specific BT-11/CC2541 checksum corruption + // Only apply workaround for known problematic devices + const isBT11Device = this.isBT11CorruptionPattern(expectedChecksum); + if (isBT11Device) { + if (!this.bt11_crc_corruption_logged) { + console.log(`${this.logHead} Detected BT-11/CC2541 CRC corruption (0xff), skipping CRC check`); + this.bt11_crc_corruption_logged = true; + } + return true; + } + return false; + } + + async requestPermissionDevice() { + let newPermissionDevice = null; + const uuids = bluetoothDevices.map((device) => device.serviceUuid).filter(Boolean); + + try { + await BetaflightBluetooth.requestPermissions(); // prompt once + } catch (err) { + console.error(`${logHead} Permission request failed`, err); + gui_log(i18n.getMessage("bluetoothConnectionError", ["Permissions denied"])); + return null; + } + + try { + // Update bluetoothDevices with the actual service UUID your hardware uses + // (capture it with nRF Connect or the PWA’s device.uuids), then restore the filtered scan. + + // const options = { + // services: [...new Set(uuids)], + // optionalServices: uuids, + // acceptAllDevices: uuids.length === 0, + // }; + const options = {}; + const userSelectedDevice = await BetaflightBluetooth.requestDevice(options); + console.log(`${logHead} User selected Bluetooth device:`, userSelectedDevice); + newPermissionDevice = this.handleNewDevice(userSelectedDevice); + console.info(`${logHead} User selected Bluetooth device from permissions:`, newPermissionDevice.path); + } catch (error) { + console.error(`${logHead} User didn't select any Bluetooth device when requesting permission:`, error); + } + return newPermissionDevice; + } + + async getDevices() { + return this.devices; + } + + async connect(path, options = {}) { + this.openRequested = true; + this.closeRequested = false; + const requestedDevice = this.devices.find((device) => device.path === path); + + if (!requestedDevice) { + console.error(`${logHead} Requested device ${path} not found`); + this.openRequested = false; + this.dispatchEvent(new CustomEvent("connect", { detail: false })); + return false; + } + + this.device = requestedDevice; + this.logHead = logHead; + + const deviceDescription = this.resolveDeviceDescription(requestedDevice.device); // || { serviceUuid: info.service }; + if (!deviceDescription) { + console.error(`${logHead} Unsupported device: missing known service UUID`); + this.openRequested = false; + gui_log(i18n.getMessage("bluetoothConnectionError", ["Unsupported device"])); + this.dispatchEvent(new CustomEvent("connect", { detail: false })); + return false; + } + + this.deviceDescription = deviceDescription; + + try { + console.log(`${logHead} Connecting to device ${requestedDevice}`); + + console.log(`${logHead} Device Description:`, deviceDescription); + console.log(`${logHead} Options:`, options); + console.log(`${logHead} Device ID:`, requestedDevice.device.deviceId); + + await BetaflightBluetooth.connect({ deviceId: requestedDevice.device.deviceId }, (deviceId) => { + void this.handleRemoteDisconnect(deviceId); + }); + + gui_log(i18n.getMessage("bluetoothConnected", [requestedDevice.device.name])); + await this.startNotifications(); + + this.connected = true; + this.connectionId = path; + const baudRate = options?.baudRate ?? 115200; + this.bitrate = baudRate; + this.bytesReceived = 0; + this.bytesSent = 0; + this.failed = 0; + this.connectionInfo = { deviceId: requestedDevice.device.deviceId }; + this.openRequested = false; + this.disconnectHandled = false; + + console.log(`${logHead} Connection opened with ID: ${this.connectionId}, Baud: ${baudRate}`); + + this.dispatchEvent(new CustomEvent("connect", { detail: this.connectionInfo })); + return true; + } catch (error) { + console.error(`${logHead} Failed to open bluetooth device`, error); + gui_log(i18n.getMessage("bluetoothConnectionError", [error])); + this.openRequested = false; + this.dispatchEvent(new CustomEvent("connect", { detail: false })); + this.cleanupConnectionState(); + return false; + } + } + + resolveDeviceDescription(device) { + console.log(`${logHead} Resolving device description for device:`, device); + const uuids = device?.uuids?.map((uuid) => toLowerUuid(uuid)) ?? []; + console.log(`${logHead} Device UUIDs:`, uuids); + let description = bluetoothDevices.find((candidate) => uuids.includes(toLowerUuid(candidate.serviceUuid))); + + if (!description && device?.name) { + description = bluetoothDevices.find( + (candidate) => candidate.name?.toLowerCase() === device.name.toLowerCase(), + ); + } + console.log(`${logHead} Resolved device description:`, description); + return description; + } + + handleNotification(value) { + let buffer = value; + if (!(buffer instanceof Uint8Array) && value?.buffer) { + buffer = new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + if (!(buffer instanceof Uint8Array)) { + return; + } + this.bytesReceived += buffer.length; + this.dispatchEvent(new CustomEvent("receive", { detail: buffer })); + } + + async startNotifications() { + if (!this.deviceDescription || !this.device) { + throw new Error("Cannot start notifications without an active device"); + } + + if (this.notificationActive) { + return; + } + + await BetaflightBluetooth.startNotifications({ + deviceId: this.device.device.deviceId, + service: this.deviceDescription.serviceUuid, + characteristic: this.deviceDescription.readCharacteristic, + }); + + this.notificationActive = true; + } + + async stopNotifications() { + if (!this.notificationActive || !this.deviceDescription || !this.device) { + return; + } + + try { + await BetaflightBluetooth.stopNotifications({ + deviceId: this.device.device.deviceId, + service: this.deviceDescription.serviceUuid, + characteristic: this.deviceDescription.readCharacteristic, + }); + } catch (error) { + console.warn(`${logHead} Failed to stop notifications`, error); + } finally { + this.notificationActive = false; + } + } + + async disconnect() { + if (this.closeRequested) { + return true; + } + + this.closeRequested = true; + + const targetDeviceId = this.device?.device?.deviceId ?? this.connectionId ?? "unknown"; + + try { + await this.stopNotifications(); + + if (this.device?.device?.deviceId) { + try { + await BetaflightBluetooth.disconnect(this.device.device.deviceId); + } catch (error) { + console.warn(`${logHead} Disconnect call failed`, error); + } + } + + await this.handleRemoteDisconnect(targetDeviceId, true); + return true; + } catch (error) { + console.error(`${logHead} Failed to close connection with ID: ${this.connectionId}`, error); + await this.handleRemoteDisconnect(targetDeviceId, true); + return false; + } finally { + this.closeRequested = false; + if (this.openCanceled) { + this.openCanceled = false; + } + } + } + + async send(data, cb) { + if (!this.connected || !this.deviceDescription || !this.device) { + if (cb) { + cb({ + error: "No write characteristic available or device is disconnected", + bytesSent: 0, + }); + } + console.error(`${logHead} No write characteristic available or device is disconnected`); + return { bytesSent: 0, error: "disconnected" }; + } + + const dataBuffer = toUint8Array(data); + const payloadSize = dataBuffer.byteLength; + const base64Value = toBase64(dataBuffer); + + const writeTask = this.writeQueue.then(async () => { + await BetaflightBluetooth.write({ + deviceId: this.device.device.deviceId, + service: this.deviceDescription.serviceUuid, + characteristic: this.deviceDescription.writeCharacteristic, + value: base64Value, + encoding: "base64", + }); + this.bytesSent += payloadSize; + }); + + this.writeQueue = writeTask.catch(() => {}); + + try { + await writeTask; + const result = { bytesSent: payloadSize }; + cb?.({ error: null, bytesSent: payloadSize }); + return result; + } catch (error) { + console.error(`${logHead} Failed to send data:`, error); + cb?.({ error, bytesSent: 0 }); + return { error, bytesSent: 0 }; + } + } + + async handleRemoteDisconnect(deviceId, forced = false) { + if (this.disconnectHandled && !forced) { + return; + } + + this.disconnectHandled = true; + + console.warn(`${logHead} Device ${deviceId} disconnected`); + await this.stopNotifications(); + console.log( + `${logHead} Connection with ID: ${this.connectionId} closed, Sent: ${this.bytesSent} bytes, Received: ${this.bytesReceived} bytes`, + ); + this.cleanupConnectionState(); + this.dispatchEvent(new CustomEvent("disconnect", { detail: true })); + } + + cleanupConnectionState() { + this.connected = false; + this.connectionId = null; + this.bitrate = 0; + this.device = null; + this.deviceDescription = null; + this.notificationActive = false; + this.writeCharacteristic = null; + this.readCharacteristic = null; + this.connectionInfo = null; + this.bt11_crc_corruption_logged = false; + this.writeQueue = Promise.resolve(); + this.transmitting = false; + this.bytesSent = 0; + this.bytesReceived = 0; + this.openRequested = false; + this.closeRequested = false; + this.disconnectHandled = true; + } + + attachNativeListeners() { + if (this.nativeListenersReady || typeof BetaflightBluetooth?.addListener !== "function") { + return; + } + + this.nativeListenersReady = true; + + const registerListener = (eventName, callback) => { + try { + const handle = BetaflightBluetooth.addListener(eventName, callback); + if (handle && typeof handle.then === "function") { + handle + .then((resolved) => { + if (resolved) { + this.nativeListeners.push(resolved); + } + }) + .catch((error) => + console.warn(`${this.logHead} Failed to attach ${eventName} listener`, error), + ); + } else if (handle && typeof handle.remove === "function") { + this.nativeListeners.push(handle); + } + } catch (error) { + console.warn(`${this.logHead} Listener registration failed for ${eventName}`, error); + } + }; + + registerListener("notification", (event) => { + if (!event?.value) { + return; + } + if (this.device?.device?.deviceId && event.deviceId && event.deviceId !== this.device.device.deviceId) { + return; + } + const buffer = fromBase64(event.value); + if (buffer) { + this.handleNotification(buffer); + } + }); + + registerListener("connectionState", (event) => { + if (event?.connected === false) { + void this.handleRemoteDisconnect(event.deviceId ?? "unknown"); + } + }); + } +} + +export default CapacitorBluetooth; diff --git a/src/js/protocols/devices.js b/src/js/protocols/devices.js index 6dcb45c0aae..0d88552da16 100644 --- a/src/js/protocols/devices.js +++ b/src/js/protocols/devices.js @@ -48,6 +48,12 @@ export const bluetoothDevices = [ writeCharacteristic: "0000db33-0000-1000-8000-00805f9b34fb", readCharacteristic: "0000db34-0000-1000-8000-00805f9b34fb", }, + { + name: "SpeedyBee F7V3", + serviceUuid: "0000abf0-0000-1000-8000-00805f9b34fb", + writeCharacteristic: "0000abf1-0000-1000-8000-00805f9b34fb", + readCharacteristic: "0000abf2-0000-1000-8000-00805f9b34fb", + }, ]; export const serialDevices = [ diff --git a/src/js/serial.js b/src/js/serial.js index 0633aeada52..39ca7788001 100644 --- a/src/js/serial.js +++ b/src/js/serial.js @@ -4,6 +4,7 @@ import Websocket from "./protocols/WebSocket.js"; import VirtualSerial from "./protocols/VirtualSerial.js"; import { isAndroid } from "./utils/checkCompatibility.js"; import CapacitorSerial from "./protocols/CapacitorSerial.js"; +import CapacitorBluetooth from "./protocols/CapacitorBluetooth.js"; /** * Base Serial class that manages all protocol implementations @@ -20,7 +21,10 @@ class Serial extends EventTarget { // Initialize protocols with metadata for easier lookup if (isAndroid()) { - this._protocols = [{ name: "serial", instance: new CapacitorSerial() }]; + this._protocols = [ + { name: "serial", instance: new CapacitorSerial() }, + { name: "bluetooth", instance: new CapacitorBluetooth() }, + ]; } else { this._protocols = [ { name: "serial", instance: new WebSerial() }, diff --git a/src/js/utils/checkCompatibility.js b/src/js/utils/checkCompatibility.js index d0a0a01b3b3..4b44c3cdacd 100644 --- a/src/js/utils/checkCompatibility.js +++ b/src/js/utils/checkCompatibility.js @@ -141,7 +141,7 @@ export function checkSerialSupport() { export function checkBluetoothSupport() { let result = false; if (isAndroid()) { - // Not implemented yet + result = true; } else if (navigator.bluetooth) { result = true; } else if (isIOS()) { From 8ac880983e7922ce5d156379a797c98cb8f59c8f Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 28 Nov 2025 22:56:43 +0100 Subject: [PATCH 02/17] Improve --- .../bluetooth/BetaflightBluetoothPlugin.java | 30 ++- src/js/protocols/CapacitorBluetooth.js | 191 ++++++++++++++++-- src/js/protocols/devices.js | 6 - 3 files changed, 207 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index 94822da7e82..6b3b8d8792f 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -300,6 +300,14 @@ public void write(PluginCall call) { return; } + int properties = characteristic.getProperties(); + boolean supportsWrite = (properties & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0; + boolean supportsWriteNoResponse = (properties & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; + if (!supportsWrite && !supportsWriteNoResponse) { + call.reject("Characteristic does not support write operations"); + return; + } + byte[] payload; try { payload = decodePayload(value, encoding); @@ -308,7 +316,15 @@ public void write(PluginCall call) { return; } - int writeType = withoutResponse ? BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE : BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; + int writeType; + if (withoutResponse) { + writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; + } else if (!supportsWrite && supportsWriteNoResponse) { + writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; + Log.d(TAG, "Characteristic " + characteristicUuid + " does not support acknowledged writes; falling back to no response mode"); + } else { + writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; + } boolean submitted = submitWrite(gatt, characteristic, payload, writeType); if (!submitted) { @@ -924,7 +940,17 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { logGattLayout("Services discovered", gatt); JSArray services = new JSArray(); for (BluetoothGattService service : gatt.getServices()) { - services.put(service.getUuid().toString()); + JSObject servicePayload = new JSObject(); + servicePayload.put("uuid", service.getUuid().toString()); + JSArray characteristics = new JSArray(); + for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) { + JSObject characteristicPayload = new JSObject(); + characteristicPayload.put("uuid", characteristic.getUuid().toString()); + characteristicPayload.put("properties", characteristic.getProperties()); + characteristics.put(characteristicPayload); + } + servicePayload.put("characteristics", characteristics); + services.put(servicePayload); } JSObject payload = new JSObject(); payload.put("deviceId", connectedDeviceId); diff --git a/src/js/protocols/CapacitorBluetooth.js b/src/js/protocols/CapacitorBluetooth.js index 2536a7d8d7c..8e831d3f177 100644 --- a/src/js/protocols/CapacitorBluetooth.js +++ b/src/js/protocols/CapacitorBluetooth.js @@ -9,6 +9,14 @@ const logHead = "[CAPACITOR-BLUETOOTH]"; const toLowerUuid = (uuid) => uuid?.toLowerCase?.() ?? uuid; +const GATT_PROPERTIES = { + READ: 0x02, + WRITE_NO_RESPONSE: 0x04, + WRITE: 0x08, + NOTIFY: 0x10, + INDICATE: 0x20, +}; + const toUint8Array = (data) => { if (data instanceof Uint8Array) { return data; @@ -82,6 +90,9 @@ class CapacitorBluetooth extends EventTarget { this.disconnectHandled = true; this.nativeListeners = []; this.nativeListenersReady = false; + this.discoveredServices = new Map(); + this.pendingServiceResolvers = new Map(); + this.serviceResolutionTimeoutMs = 8000; this.handleNotification = this.handleNotification.bind(this); this.handleRemoteDisconnect = this.handleRemoteDisconnect.bind(this); @@ -213,13 +224,11 @@ class CapacitorBluetooth extends EventTarget { this.device = requestedDevice; this.logHead = logHead; - const deviceDescription = this.resolveDeviceDescription(requestedDevice.device); // || { serviceUuid: info.service }; + const deviceDescription = this.resolveDeviceDescription(requestedDevice.device) ?? null; if (!deviceDescription) { - console.error(`${logHead} Unsupported device: missing known service UUID`); - this.openRequested = false; - gui_log(i18n.getMessage("bluetoothConnectionError", ["Unsupported device"])); - this.dispatchEvent(new CustomEvent("connect", { detail: false })); - return false; + console.warn( + `${logHead} No static profile for device ${requestedDevice.device?.name ?? requestedDevice.path}`, + ); } this.deviceDescription = deviceDescription; @@ -235,6 +244,17 @@ class CapacitorBluetooth extends EventTarget { void this.handleRemoteDisconnect(deviceId); }); + const effectiveDescription = await this.waitForDeviceCharacteristics( + requestedDevice.device.deviceId, + deviceDescription, + ); + + if (!this.hasCompleteCharacteristics(effectiveDescription)) { + throw new Error("Unable to determine BLE service and characteristics for device"); + } + + this.deviceDescription = effectiveDescription; + gui_log(i18n.getMessage("bluetoothConnected", [requestedDevice.device.name])); await this.startNotifications(); @@ -258,7 +278,7 @@ class CapacitorBluetooth extends EventTarget { gui_log(i18n.getMessage("bluetoothConnectionError", [error])); this.openRequested = false; this.dispatchEvent(new CustomEvent("connect", { detail: false })); - this.cleanupConnectionState(); + this.cleanupConnectionState(requestedDevice.device?.deviceId ?? null); return false; } } @@ -333,7 +353,7 @@ class CapacitorBluetooth extends EventTarget { this.closeRequested = true; - const targetDeviceId = this.device?.device?.deviceId ?? this.connectionId ?? "unknown"; + const targetDeviceId = this.device?.device?.deviceId ?? this.connectionId ?? null; try { await this.stopNotifications(); @@ -407,17 +427,18 @@ class CapacitorBluetooth extends EventTarget { } this.disconnectHandled = true; + const activeDeviceId = deviceId ?? this.device?.device?.deviceId ?? null; - console.warn(`${logHead} Device ${deviceId} disconnected`); + console.warn(`${logHead} Device ${activeDeviceId ?? "unknown"} disconnected`); await this.stopNotifications(); console.log( `${logHead} Connection with ID: ${this.connectionId} closed, Sent: ${this.bytesSent} bytes, Received: ${this.bytesReceived} bytes`, ); - this.cleanupConnectionState(); + this.cleanupConnectionState(activeDeviceId); this.dispatchEvent(new CustomEvent("disconnect", { detail: true })); } - cleanupConnectionState() { + cleanupConnectionState(deviceId = null) { this.connected = false; this.connectionId = null; this.bitrate = 0; @@ -435,6 +456,148 @@ class CapacitorBluetooth extends EventTarget { this.openRequested = false; this.closeRequested = false; this.disconnectHandled = true; + if (deviceId) { + this.clearPendingServiceResolver(deviceId); + } + } + + hasCompleteCharacteristics(description) { + return Boolean(description?.serviceUuid && description?.writeCharacteristic && description?.readCharacteristic); + } + + async waitForDeviceCharacteristics(deviceId, fallbackDescription) { + if (!deviceId) { + return fallbackDescription ?? null; + } + + const cached = this.getCachedDeviceDescription(deviceId, fallbackDescription); + if (this.hasCompleteCharacteristics(cached)) { + return cached; + } + + return this.createPendingServicePromise(deviceId, cached ?? fallbackDescription ?? null); + } + + getCachedDeviceDescription(deviceId, fallbackDescription) { + const services = this.discoveredServices.get(deviceId); + if (!services) { + return fallbackDescription ?? null; + } + const derived = this.buildDescriptionFromServices(services); + if (!derived) { + return fallbackDescription ?? null; + } + return this.mergeDeviceDescription(fallbackDescription, derived); + } + + mergeDeviceDescription(baseDescription, overrideDescription) { + if (!baseDescription) { + return overrideDescription ? { ...overrideDescription } : null; + } + if (!overrideDescription) { + return { ...baseDescription }; + } + return { ...baseDescription, ...overrideDescription }; + } + + createPendingServicePromise(deviceId, fallbackDescription) { + const existing = this.pendingServiceResolvers.get(deviceId); + if (existing) { + return existing.promise; + } + + const pending = { + fallback: fallbackDescription ? { ...fallbackDescription } : null, + }; + pending.promise = new Promise((resolve) => { + pending.resolve = (description) => { + clearTimeout(pending.timeout); + this.pendingServiceResolvers.delete(deviceId); + if (description) { + resolve(description); + } else { + resolve(pending.fallback); + } + }; + }); + pending.timeout = setTimeout(() => pending.resolve(null), this.serviceResolutionTimeoutMs); + this.pendingServiceResolvers.set(deviceId, pending); + return pending.promise; + } + + buildDescriptionFromServices(services = []) { + for (const service of services) { + if (!service) { + continue; + } + const serviceUuid = toLowerUuid(service.uuid ?? service.serviceUuid); + if (!serviceUuid) { + continue; + } + const characteristics = Array.isArray(service.characteristics) ? service.characteristics : []; + if (characteristics.length === 0) { + continue; + } + const writeCandidate = characteristics.find((characteristic) => + this.characteristicSupportsWrite(characteristic?.properties), + ); + if (!writeCandidate?.uuid) { + continue; + } + const notifyCandidate = + characteristics.find((characteristic) => + this.characteristicSupportsNotify(characteristic?.properties), + ) || writeCandidate; + if (!notifyCandidate?.uuid) { + continue; + } + return { + serviceUuid, + writeCharacteristic: toLowerUuid(writeCandidate.uuid), + readCharacteristic: toLowerUuid(notifyCandidate.uuid), + }; + } + return null; + } + + characteristicSupportsWrite(properties = 0) { + return (properties & (GATT_PROPERTIES.WRITE | GATT_PROPERTIES.WRITE_NO_RESPONSE)) !== 0; + } + + characteristicSupportsNotify(properties = 0) { + return (properties & (GATT_PROPERTIES.NOTIFY | GATT_PROPERTIES.INDICATE)) !== 0; + } + + handleServicesEvent(event) { + const deviceId = event?.deviceId; + const services = event?.services; + if (!deviceId || !Array.isArray(services)) { + return; + } + + this.discoveredServices.set(deviceId, services); + const derived = this.buildDescriptionFromServices(services); + const pending = this.pendingServiceResolvers.get(deviceId); + if (pending) { + pending.resolve(this.mergeDeviceDescription(pending.fallback, derived)); + } + + if (derived && this.device?.device?.deviceId === deviceId) { + this.deviceDescription = this.mergeDeviceDescription(this.deviceDescription, derived); + } + } + + clearPendingServiceResolver(deviceId) { + if (!deviceId) { + return; + } + const pending = this.pendingServiceResolvers.get(deviceId); + if (pending) { + clearTimeout(pending.timeout); + this.pendingServiceResolvers.delete(deviceId); + pending.resolve(null); + } + this.discoveredServices.delete(deviceId); } attachNativeListeners() { @@ -480,9 +643,13 @@ class CapacitorBluetooth extends EventTarget { registerListener("connectionState", (event) => { if (event?.connected === false) { - void this.handleRemoteDisconnect(event.deviceId ?? "unknown"); + void this.handleRemoteDisconnect(event.deviceId ?? null); } }); + + registerListener("services", (event) => { + this.handleServicesEvent(event); + }); } } diff --git a/src/js/protocols/devices.js b/src/js/protocols/devices.js index 0d88552da16..6dcb45c0aae 100644 --- a/src/js/protocols/devices.js +++ b/src/js/protocols/devices.js @@ -48,12 +48,6 @@ export const bluetoothDevices = [ writeCharacteristic: "0000db33-0000-1000-8000-00805f9b34fb", readCharacteristic: "0000db34-0000-1000-8000-00805f9b34fb", }, - { - name: "SpeedyBee F7V3", - serviceUuid: "0000abf0-0000-1000-8000-00805f9b34fb", - writeCharacteristic: "0000abf1-0000-1000-8000-00805f9b34fb", - readCharacteristic: "0000abf2-0000-1000-8000-00805f9b34fb", - }, ]; export const serialDevices = [ From 3a10ff69b0daf9fa77c31b01da8fb00b3b58203c Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 28 Nov 2025 23:08:49 +0100 Subject: [PATCH 03/17] Fix race condition: ArrayList is not thread-safe. --- .../bluetooth/BetaflightBluetoothPlugin.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index 6b3b8d8792f..c8f47e4b9e8 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -43,8 +43,10 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Queue; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; /** @@ -104,7 +106,7 @@ private static final class DiscoveredDevice { private Runnable connectTimeoutRunnable; private PluginCall pendingConnectCall; private String connectedDeviceId; - private final List pendingStartNotificationCalls = new ArrayList<>(); + private final Queue pendingStartNotificationCalls = new ConcurrentLinkedQueue<>(); private volatile boolean servicesDiscovered = false; @Override @@ -832,9 +834,8 @@ private void flushPendingStartNotificationCalls() { if (!servicesDiscovered || pendingStartNotificationCalls.isEmpty()) { return; } - List queued = new ArrayList<>(pendingStartNotificationCalls); - pendingStartNotificationCalls.clear(); - for (PluginCall pendingCall : queued) { + PluginCall pendingCall; + while ((pendingCall = pendingStartNotificationCalls.poll()) != null) { startNotificationsInternal(pendingCall); } } @@ -843,9 +844,8 @@ private void rejectPendingStartNotifications(String reason) { if (pendingStartNotificationCalls.isEmpty()) { return; } - List queued = new ArrayList<>(pendingStartNotificationCalls); - pendingStartNotificationCalls.clear(); - for (PluginCall pendingCall : queued) { + PluginCall pendingCall; + while ((pendingCall = pendingStartNotificationCalls.poll()) != null) { pendingCall.setKeepAlive(false); pendingCall.reject(reason); } From d7fa71b7fcd205b2a539c53f2cc1c51a53163453 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 28 Nov 2025 23:10:54 +0100 Subject: [PATCH 04/17] Fix missing deprecated onCharacteristicChanged override for Android 12 and below. --- .../protocols/bluetooth/BetaflightBluetoothPlugin.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index c8f47e4b9e8..5386f6cf510 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -958,6 +958,13 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { notifyListeners("services", payload); } + @Deprecated + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + byte[] value = characteristic != null ? characteristic.getValue() : null; + onCharacteristicChanged(gatt, characteristic, value); + } + @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { JSObject payload = new JSObject(); From 619ba7f04c173d842a5a518dcb735bff7f1b96d1 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 28 Nov 2025 23:23:42 +0100 Subject: [PATCH 05/17] Add connection guard to prevent overlapping requests --- src/js/serial_backend.js | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/js/serial_backend.js b/src/js/serial_backend.js index 37cd4ae02ff..b8001e9488f 100644 --- a/src/js/serial_backend.js +++ b/src/js/serial_backend.js @@ -61,13 +61,13 @@ export function initializeSerialBackend() { EventBus.$on("port-handler:auto-select-serial-device", function () { if ( - !GUI.connected_to && - !GUI.connecting_to && - !["cli", "firmware_flasher"].includes(GUI.active_tab) && - PortHandler.portPicker.autoConnect && - !isCliOnlyMode() && - (connectionTimestamp === null || connectionTimestamp > 0) || - (Date.now() - rebootTimestamp <= REBOOT_CONNECT_MAX_TIME_MS) + (!GUI.connected_to && + !GUI.connecting_to && + !["cli", "firmware_flasher"].includes(GUI.active_tab) && + PortHandler.portPicker.autoConnect && + !isCliOnlyMode() && + (connectionTimestamp === null || connectionTimestamp > 0)) || + Date.now() - rebootTimestamp <= REBOOT_CONNECT_MAX_TIME_MS ) { connectDisconnect(); } @@ -152,11 +152,16 @@ function connectDisconnect() { serial.addEventListener("disconnect", disconnectHandler); } - serial.connect( - portName, - { baudRate: PortHandler.portPicker.selectedBauds }, - selectedPort === "virtual" ? onOpenVirtual : undefined, - ); + GUI.connect_lock = true; + serial + .connect( + portName, + { baudRate: PortHandler.portPicker.selectedBauds }, + selectedPort === "virtual" ? onOpenVirtual : undefined, + ) + .finally(() => { + GUI.connect_lock = false; + }); } // show CLI panel on Control+I From 15a29f0651feb0a1144dcf6fe5b0f46c3863b3b5 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 21:23:26 +0100 Subject: [PATCH 06/17] Fix permissions --- android/app/src/main/AndroidManifest.xml | 32 ++--- .../bluetooth/BetaflightBluetoothPlugin.java | 109 ++++++++++++++++-- src/js/protocols/CapacitorBluetooth.js | 10 +- 3 files changed, 117 insertions(+), 34 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e9d4ef15b1e..adfc2eec3cf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -55,25 +55,25 @@ - - - - - + + + + + + + + + - - - - - + diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index 5386f6cf510..f86a4ff220a 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -49,6 +49,11 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import android.content.pm.PackageManager; +import android.content.Intent; + /** * Custom Capacitor plugin that provides BLE scanning, connection management, and * MSP-friendly binary transport for Betaflight Configurator. @@ -58,13 +63,14 @@ permissions = { @Permission( alias = "bluetooth", - strings = { - Manifest.permission.BLUETOOTH, - Manifest.permission.BLUETOOTH_ADMIN, - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.ACCESS_FINE_LOCATION - } + strings = {} + // strings = { + // Manifest.permission.BLUETOOTH, + // Manifest.permission.BLUETOOTH_ADMIN, + // Manifest.permission.BLUETOOTH_SCAN, + // Manifest.permission.BLUETOOTH_CONNECT, + // Manifest.permission.ACCESS_FINE_LOCATION + // } ) } ) @@ -139,15 +145,100 @@ public void checkStatus(PluginCall call) { call.resolve(result); } + // @PluginMethod + // public void requestPermissions(PluginCall call) { + // if (getPermissionState("bluetooth") == PermissionState.GRANTED) { + // JSObject result = new JSObject(); + // result.put("granted", true); + // call.resolve(result); + // return; + // } + // requestPermissionForAlias("bluetooth", call, "onPermissionResult"); + // } + @PluginMethod public void requestPermissions(PluginCall call) { - if (getPermissionState("bluetooth") == PermissionState.GRANTED) { + // Determine required permissions based on Android version + String[] requiredPermissions = getRequiredPermissions(); + + // Check if all required permissions are already granted + boolean allGranted = true; + for (String permission : requiredPermissions) { + if (ContextCompat.checkSelfPermission(getContext(), permission) + != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (allGranted) { JSObject result = new JSObject(); result.put("granted", true); call.resolve(result); return; } - requestPermissionForAlias("bluetooth", call, "onPermissionResult"); + + // Store the call for later resolution + pendingPermissionCall = call; + + // Request permissions using Activity's native method + ActivityCompat.requestPermissions( + getActivity(), + requiredPermissions, + BLUETOOTH_PERMISSION_REQUEST_CODE + ); + } + + // Add these as class fields + private static final int BLUETOOTH_PERMISSION_REQUEST_CODE = 9002; + private PluginCall pendingPermissionCall; + + // Helper to get version-specific permissions + private String[] getRequiredPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ + return new String[]{ + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + }; + } else { // Android 8-11 + return new String[]{ + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN, + Manifest.permission.ACCESS_COARSE_LOCATION + }; + } + } + + // Handle the permission result from the Activity + @Override + protected void handleOnActivityResult(int requestCode, int resultCode, Intent data) { + super.handleOnActivityResult(requestCode, resultCode, data); + } + + // This is called by the Activity when permissions are granted/denied + @Override + protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.handleRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == BLUETOOTH_PERMISSION_REQUEST_CODE && pendingPermissionCall != null) { + boolean allGranted = true; + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (allGranted) { + JSObject result = new JSObject(); + result.put("granted", true); + pendingPermissionCall.resolve(result); + } else { + pendingPermissionCall.reject("Bluetooth permissions denied"); + } + + pendingPermissionCall = null; + } } @PermissionCallback diff --git a/src/js/protocols/CapacitorBluetooth.js b/src/js/protocols/CapacitorBluetooth.js index 8e831d3f177..54476050b8b 100644 --- a/src/js/protocols/CapacitorBluetooth.js +++ b/src/js/protocols/CapacitorBluetooth.js @@ -186,15 +186,7 @@ class CapacitorBluetooth extends EventTarget { } try { - // Update bluetoothDevices with the actual service UUID your hardware uses - // (capture it with nRF Connect or the PWA’s device.uuids), then restore the filtered scan. - - // const options = { - // services: [...new Set(uuids)], - // optionalServices: uuids, - // acceptAllDevices: uuids.length === 0, - // }; - const options = {}; + const options = { acceptAllDevices: true }; // Android workaround const userSelectedDevice = await BetaflightBluetooth.requestDevice(options); console.log(`${logHead} User selected Bluetooth device:`, userSelectedDevice); newPermissionDevice = this.handleNewDevice(userSelectedDevice); From bff08c7c77d1cb2d7b2f217febae5067b4a79a75 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 21:32:02 +0100 Subject: [PATCH 07/17] Add null safety checks for characteristic and value --- .../protocols/bluetooth/BetaflightBluetoothPlugin.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index f86a4ff220a..baf4ddff7e5 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -1052,6 +1052,10 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { @Deprecated @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if (characteristic == null || value == null) { + Log.w(TAG, "Received notification with null characteristic or value"); + return; + } byte[] value = characteristic != null ? characteristic.getValue() : null; onCharacteristicChanged(gatt, characteristic, value); } From 00e1ac41a1224cd80fb2761c093227373b38983c Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 21:33:34 +0100 Subject: [PATCH 08/17] Remove unused var --- src/js/protocols/CapacitorBluetooth.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/protocols/CapacitorBluetooth.js b/src/js/protocols/CapacitorBluetooth.js index 54476050b8b..064a4222895 100644 --- a/src/js/protocols/CapacitorBluetooth.js +++ b/src/js/protocols/CapacitorBluetooth.js @@ -175,7 +175,6 @@ class CapacitorBluetooth extends EventTarget { async requestPermissionDevice() { let newPermissionDevice = null; - const uuids = bluetoothDevices.map((device) => device.serviceUuid).filter(Boolean); try { await BetaflightBluetooth.requestPermissions(); // prompt once From 20868c3aac15b0e67fe5e116e52775e71dac4ef2 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 21:37:08 +0100 Subject: [PATCH 09/17] Resolve coderabbitai suggestion --- .../protocols/bluetooth/BetaflightBluetoothPlugin.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index baf4ddff7e5..c865556e6e8 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -1052,7 +1052,7 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { @Deprecated @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { - if (characteristic == null || value == null) { + if (characteristic == null) { Log.w(TAG, "Received notification with null characteristic or value"); return; } From f55c55e853be37b18620ab0ffdd50cab26c965e6 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 21:39:31 +0100 Subject: [PATCH 10/17] Resolve coderabbitai suggestion... --- .../protocols/bluetooth/BetaflightBluetoothPlugin.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index c865556e6e8..e55dc4618a8 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -1052,16 +1052,16 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { @Deprecated @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { - if (characteristic == null) { - Log.w(TAG, "Received notification with null characteristic or value"); - return; - } byte[] value = characteristic != null ? characteristic.getValue() : null; onCharacteristicChanged(gatt, characteristic, value); } @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { + if (characteristic == null || value == null) { + Log.w(TAG, "Received notification with null characteristic or value"); + return; + } JSObject payload = new JSObject(); payload.put("deviceId", connectedDeviceId); payload.put("service", characteristic.getService().getUuid().toString()); From c4bf72d60d7446c5673e4594f5413a61516133b6 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 22:05:27 +0100 Subject: [PATCH 11/17] Little cleanup --- .../bluetooth/BetaflightBluetoothPlugin.java | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index e55dc4618a8..fb8da2b2943 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -64,13 +64,6 @@ @Permission( alias = "bluetooth", strings = {} - // strings = { - // Manifest.permission.BLUETOOTH, - // Manifest.permission.BLUETOOTH_ADMIN, - // Manifest.permission.BLUETOOTH_SCAN, - // Manifest.permission.BLUETOOTH_CONNECT, - // Manifest.permission.ACCESS_FINE_LOCATION - // } ) } ) @@ -145,17 +138,6 @@ public void checkStatus(PluginCall call) { call.resolve(result); } - // @PluginMethod - // public void requestPermissions(PluginCall call) { - // if (getPermissionState("bluetooth") == PermissionState.GRANTED) { - // JSObject result = new JSObject(); - // result.put("granted", true); - // call.resolve(result); - // return; - // } - // requestPermissionForAlias("bluetooth", call, "onPermissionResult"); - // } - @PluginMethod public void requestPermissions(PluginCall call) { // Determine required permissions based on Android version @@ -189,11 +171,9 @@ public void requestPermissions(PluginCall call) { ); } - // Add these as class fields private static final int BLUETOOTH_PERMISSION_REQUEST_CODE = 9002; private PluginCall pendingPermissionCall; - // Helper to get version-specific permissions private String[] getRequiredPermissions() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ return new String[]{ From 3e40056c502882c6c051677baa2bca2a8496a8f7 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 30 Nov 2025 22:42:29 +0100 Subject: [PATCH 12/17] Remove unneeded ACCESS_FINE_LOCATION permissions --- android/app/src/main/AndroidManifest.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index adfc2eec3cf..80a47f814db 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -65,9 +65,6 @@ - - From ebd3b3adec4fe53c6495c8f8fa590187f1204c94 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Tue, 2 Dec 2025 16:06:57 +0100 Subject: [PATCH 13/17] Add deprecated 2-arg callback for backward compatibility (API < 33) --- .../bluetooth/BetaflightBluetoothPlugin.java | 19 +++++++++++++++---- src/js/msp/MSPHelper.js | 7 ++++++- src/js/protocols/CapacitorBluetooth.js | 17 +++++++++++------ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index fb8da2b2943..3fe19054f32 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -993,7 +993,7 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { connectionState.set(ConnectionState.CONNECTED); gatt.discoverServices(); - connectedSuccessfully(); + // connectedSuccessfully() now called after service discovery } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { connectionState.set(ConnectionState.DISCONNECTED); cleanupGatt(); @@ -1027,20 +1027,31 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { payload.put("deviceId", connectedDeviceId); payload.put("services", services); notifyListeners("services", payload); + // Now resolve the connect call after services are discovered + connectedSuccessfully(); } + /** + * Deprecated 2-arg callback for backward compatibility (API < 33). + * Forwards to the 3-arg version. + */ @Deprecated + @SuppressWarnings("deprecation") @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { - byte[] value = characteristic != null ? characteristic.getValue() : null; + byte[] value = (characteristic != null) ? characteristic.getValue() : null; + if (characteristic == null || value == null) { + Log.w(TAG, "Received notification with null characteristic or value (2-arg)"); + return; + } onCharacteristicChanged(gatt, characteristic, value); } @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { if (characteristic == null || value == null) { - Log.w(TAG, "Received notification with null characteristic or value"); - return; + Log.w(TAG, "Received notification with null characteristic or value"); + return; } JSObject payload = new JSObject(); payload.put("deviceId", connectedDeviceId); diff --git a/src/js/msp/MSPHelper.js b/src/js/msp/MSPHelper.js index f175d049507..fe6c7776691 100644 --- a/src/js/msp/MSPHelper.js +++ b/src/js/msp/MSPHelper.js @@ -2933,6 +2933,11 @@ MspHelper.prototype.sendSerialConfig = function (callback) { }; MspHelper.prototype.writeConfiguration = function (reboot, callback) { + if (reboot) { + GUI.interval_kill_all(); // stop status_pull and other tab timers immediately + MSP.callbacks_cleanup(); // drop any stale callbacks + } + // We need some protection when testing motors on motors tab if (!FC.CONFIG.armingDisabled) { this.setArmingEnabled(false, false); @@ -2951,7 +2956,7 @@ MspHelper.prototype.writeConfiguration = function (reboot, callback) { callback(); } }); - }, 100); // 100ms delay before sending MSP_EEPROM_WRITE to ensure that all settings have been received + }, 200); // 200ms delay before sending MSP_EEPROM_WRITE to ensure that all settings have been received }; let mspHelper; diff --git a/src/js/protocols/CapacitorBluetooth.js b/src/js/protocols/CapacitorBluetooth.js index 064a4222895..b775ead9d44 100644 --- a/src/js/protocols/CapacitorBluetooth.js +++ b/src/js/protocols/CapacitorBluetooth.js @@ -85,7 +85,6 @@ class CapacitorBluetooth extends EventTarget { this.bt11_crc_corruption_logged = false; this.writeQueue = Promise.resolve(); - this.bleInitialized = false; this.notificationActive = false; this.disconnectHandled = true; this.nativeListeners = []; @@ -102,8 +101,9 @@ class CapacitorBluetooth extends EventTarget { handleNewDevice(devicePayload) { const resolvedDevice = devicePayload?.device ?? devicePayload; - if (!resolvedDevice?.deviceId) { - console.warn(`${this.logHead} Ignoring device without an ID`, devicePayload); + + if (!resolvedDevice?.uuids?.[0]) { + console.error(`${this.logHead} Error: Could not resolve device UUID`, devicePayload); return null; } @@ -189,11 +189,16 @@ class CapacitorBluetooth extends EventTarget { const userSelectedDevice = await BetaflightBluetooth.requestDevice(options); console.log(`${logHead} User selected Bluetooth device:`, userSelectedDevice); newPermissionDevice = this.handleNewDevice(userSelectedDevice); - console.info(`${logHead} User selected Bluetooth device from permissions:`, newPermissionDevice.path); + console.info(`${logHead} User selected Bluetooth device from permissions:`, newPermissionDevice); } catch (error) { console.error(`${logHead} User didn't select any Bluetooth device when requesting permission:`, error); } - return newPermissionDevice; + console.log( + `${logHead} Permission device after filtering:`, + newPermissionDevice, + newPermissionDevice?.device?.uuids, + ); + return newPermissionDevice?.device?.uuids?.length ? newPermissionDevice : null; } async getDevices() { @@ -372,7 +377,7 @@ class CapacitorBluetooth extends EventTarget { } async send(data, cb) { - if (!this.connected || !this.deviceDescription || !this.device) { + if (!this.connected || !this.device || !this.deviceDescription?.writeCharacteristic) { if (cb) { cb({ error: "No write characteristic available or device is disconnected", From 028bb9633c941e7b4cb6b128dce3044a02e2e040 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Tue, 2 Dec 2025 18:58:59 +0100 Subject: [PATCH 14/17] Reduce load in write I --- .../bluetooth/BetaflightBluetoothPlugin.java | 124 ++++++++---------- 1 file changed, 54 insertions(+), 70 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index 3fe19054f32..d93df678116 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -105,6 +105,7 @@ private static final class DiscoveredDevice { private Runnable connectTimeoutRunnable; private PluginCall pendingConnectCall; private String connectedDeviceId; + private BluetoothGattCharacteristic writeCharacteristic = null; private final Queue pendingStartNotificationCalls = new ConcurrentLinkedQueue<>(); private volatile boolean servicesDiscovered = false; @@ -325,63 +326,27 @@ public void disconnect(PluginCall call) { @PluginMethod public void write(PluginCall call) { - if (!ensureConnected(call)) { - return; - } - - String serviceId = call.getString("service"); - String characteristicId = call.getString("characteristic"); - String value = call.getString("value", call.getString("data")); - String encoding = call.getString("encoding", "base64"); - boolean withoutResponse = call.getBoolean("withoutResponse", false); - - if (TextUtils.isEmpty(serviceId) || TextUtils.isEmpty(characteristicId)) { - call.reject("service and characteristic are required"); - return; - } - if (TextUtils.isEmpty(value)) { - call.reject("value is required"); - return; - } + if (!ensureConnected(call)) return; - UUID serviceUuid = parseUuid(serviceId); - UUID characteristicUuid = parseUuid(characteristicId); - if (serviceUuid == null || characteristicUuid == null) { - call.reject("Invalid UUID format"); - return; - } - - BluetoothGatt gatt = bluetoothGatt; - if (gatt == null) { + final BluetoothGatt gatt = bluetoothGatt; + if (gatt == null || !servicesDiscovered) { call.reject("Not connected"); return; } - BluetoothGattService service = gatt.getService(serviceUuid); - BluetoothGattCharacteristic characteristic = service != null ? service.getCharacteristic(characteristicUuid) : null; - if (characteristic == null) { - BluetoothGattService fallback = findServiceContainingCharacteristic(gatt, characteristicUuid); - if (fallback != null) { - service = fallback; - characteristic = fallback.getCharacteristic(characteristicUuid); - Log.w(TAG, "Requested service " + serviceId + " not found, using " + service.getUuid()); - } - } - if (characteristic == null) { - logGattLayout("Characteristic lookup failure", gatt); - call.reject("Characteristic not found"); + final BluetoothGattCharacteristic target = writeCharacteristic; + if (target == null) { + call.reject("Write characteristic not available"); return; } - int properties = characteristic.getProperties(); - boolean supportsWrite = (properties & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0; - boolean supportsWriteNoResponse = (properties & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; - if (!supportsWrite && !supportsWriteNoResponse) { - call.reject("Characteristic does not support write operations"); - return; - } + // Fetch payload params from call (they were previously undefined in this method) + final String value = call.getString("value", call.getString("data")); + final String encoding = call.getString("encoding", "base64"); + final boolean withoutResponse = call.getBoolean("withoutResponse", false); - byte[] payload; + // Decode payload using your existing helper + final byte[] payload; try { payload = decodePayload(value, encoding); } catch (IllegalArgumentException ex) { @@ -389,25 +354,21 @@ public void write(PluginCall call) { return; } - int writeType; - if (withoutResponse) { - writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; - } else if (!supportsWrite && supportsWriteNoResponse) { - writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; - Log.d(TAG, "Characteristic " + characteristicUuid + " does not support acknowledged writes; falling back to no response mode"); - } else { - writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; - } + // Choose write type: prefer NO_RESPONSE when supported or requested + final int props = target.getProperties(); + final boolean canNoRsp = (props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; + final int writeType = (withoutResponse || canNoRsp) + ? BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + : BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; - boolean submitted = submitWrite(gatt, characteristic, payload, writeType); - if (!submitted) { + final boolean ok = submitWrite(gatt, target, payload, writeType); + if (ok) { + JSObject result = new JSObject(); + result.put("bytesSent", payload.length); + call.resolve(result); + } else { call.reject("Unable to write characteristic"); - return; } - - JSObject result = new JSObject(); - result.put("bytesSent", payload.length); - call.resolve(result); } @PluginMethod @@ -687,14 +648,11 @@ private void disconnectInternal(boolean fromDestroy) { @SuppressLint("MissingPermission") private void cleanupGatt() { if (bluetoothGatt != null) { - try { - bluetoothGatt.disconnect(); - } catch (Exception ignored) {} - try { - bluetoothGatt.close(); - } catch (Exception ignored) {} + try { bluetoothGatt.disconnect(); } catch (Exception ignored) {} + try { bluetoothGatt.close(); } catch (Exception ignored) {} } bluetoothGatt = null; + writeCharacteristic = null; // reset for next connection connectedDeviceId = null; servicesDiscovered = false; } @@ -1007,6 +965,32 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { return; } servicesDiscovered = true; + // Resolve and cache a write characteristic once per connection + writeCharacteristic = null; + try { + for (BluetoothGattService svc : gatt.getServices()) { + BluetoothGattCharacteristic preferred = null; + BluetoothGattCharacteristic fallback = null; + for (BluetoothGattCharacteristic ch : svc.getCharacteristics()) { + final int props = ch.getProperties(); + if ((props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { + preferred = ch; // best option for MSP + break; + } + if (fallback == null && (props & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) { + fallback = ch; + } + } + final BluetoothGattCharacteristic chosen = (preferred != null) ? preferred : fallback; + if (chosen != null) { + writeCharacteristic = chosen; + break; + } + } + } catch (Exception ignored) { + // leave writeCharacteristic null; write() will report unavailable + } + flushPendingStartNotificationCalls(); logGattLayout("Services discovered", gatt); JSArray services = new JSArray(); From 6d06c2d6509a1a507f226ac5ddd3d39e415b926a Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Tue, 2 Dec 2025 19:09:48 +0100 Subject: [PATCH 15/17] Reduce load in write II --- .../bluetooth/BetaflightBluetoothPlugin.java | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index d93df678116..c84efb52a79 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -106,6 +106,7 @@ private static final class DiscoveredDevice { private PluginCall pendingConnectCall; private String connectedDeviceId; private BluetoothGattCharacteristic writeCharacteristic = null; + private boolean writeNoResponseSupported = false; private final Queue pendingStartNotificationCalls = new ConcurrentLinkedQueue<>(); private volatile boolean servicesDiscovered = false; @@ -329,23 +330,16 @@ public void write(PluginCall call) { if (!ensureConnected(call)) return; final BluetoothGatt gatt = bluetoothGatt; - if (gatt == null || !servicesDiscovered) { - call.reject("Not connected"); - return; - } + if (gatt == null || !servicesDiscovered) { call.reject("Not connected"); return; } final BluetoothGattCharacteristic target = writeCharacteristic; - if (target == null) { - call.reject("Write characteristic not available"); - return; - } + if (target == null) { call.reject("Write characteristic not available"); return; } - // Fetch payload params from call (they were previously undefined in this method) + // Params and payload decode only final String value = call.getString("value", call.getString("data")); final String encoding = call.getString("encoding", "base64"); final boolean withoutResponse = call.getBoolean("withoutResponse", false); - // Decode payload using your existing helper final byte[] payload; try { payload = decodePayload(value, encoding); @@ -354,10 +348,8 @@ public void write(PluginCall call) { return; } - // Choose write type: prefer NO_RESPONSE when supported or requested - final int props = target.getProperties(); - final boolean canNoRsp = (props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; - final int writeType = (withoutResponse || canNoRsp) + // Minimal branch based on cached capability (no property reads here) + final int writeType = (withoutResponse && writeNoResponseSupported) ? BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE : BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; @@ -652,7 +644,8 @@ private void cleanupGatt() { try { bluetoothGatt.close(); } catch (Exception ignored) {} } bluetoothGatt = null; - writeCharacteristic = null; // reset for next connection + writeCharacteristic = null; + writeNoResponseSupported = false; connectedDeviceId = null; servicesDiscovered = false; } @@ -961,29 +954,33 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { - if (status != BluetoothGatt.GATT_SUCCESS) { - return; - } + if (status != BluetoothGatt.GATT_SUCCESS) return; servicesDiscovered = true; - // Resolve and cache a write characteristic once per connection + writeCharacteristic = null; + writeNoResponseSupported = false; + try { for (BluetoothGattService svc : gatt.getServices()) { BluetoothGattCharacteristic preferred = null; - BluetoothGattCharacteristic fallback = null; + BluetoothGattCharacteristic fallback = null; + for (BluetoothGattCharacteristic ch : svc.getCharacteristics()) { final int props = ch.getProperties(); if ((props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { - preferred = ch; // best option for MSP + preferred = ch; // best option break; } if (fallback == null && (props & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) { fallback = ch; } } + final BluetoothGattCharacteristic chosen = (preferred != null) ? preferred : fallback; if (chosen != null) { writeCharacteristic = chosen; + writeNoResponseSupported = + (chosen.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; break; } } From c0f286f65bf5108f0e448265d4694507de006d22 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Tue, 2 Dec 2025 21:22:26 +0100 Subject: [PATCH 16/17] Reduce load in write III --- .../bluetooth/BetaflightBluetoothPlugin.java | 51 ++++++++----------- src/js/msp/MSPHelper.js | 49 ++++++++++++------ 2 files changed, 54 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index c84efb52a79..16bf98cae18 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -105,8 +105,12 @@ private static final class DiscoveredDevice { private Runnable connectTimeoutRunnable; private PluginCall pendingConnectCall; private String connectedDeviceId; + + // Cached per connection private BluetoothGattCharacteristic writeCharacteristic = null; private boolean writeNoResponseSupported = false; + private int cachedWriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; + private final Queue pendingStartNotificationCalls = new ConcurrentLinkedQueue<>(); private volatile boolean servicesDiscovered = false; @@ -328,32 +332,19 @@ public void disconnect(PluginCall call) { @PluginMethod public void write(PluginCall call) { if (!ensureConnected(call)) return; - final BluetoothGatt gatt = bluetoothGatt; if (gatt == null || !servicesDiscovered) { call.reject("Not connected"); return; } final BluetoothGattCharacteristic target = writeCharacteristic; if (target == null) { call.reject("Write characteristic not available"); return; } - // Params and payload decode only final String value = call.getString("value", call.getString("data")); final String encoding = call.getString("encoding", "base64"); - final boolean withoutResponse = call.getBoolean("withoutResponse", false); - final byte[] payload; - try { - payload = decodePayload(value, encoding); - } catch (IllegalArgumentException ex) { - call.reject("Failed to decode payload: " + ex.getMessage()); - return; - } - - // Minimal branch based on cached capability (no property reads here) - final int writeType = (withoutResponse && writeNoResponseSupported) - ? BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - : BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; + try { payload = decodePayload(value, encoding); } + catch (IllegalArgumentException ex) { call.reject("Failed to decode payload: " + ex.getMessage()); return; } - final boolean ok = submitWrite(gatt, target, payload, writeType); + final boolean ok = submitWrite(gatt, target, payload, cachedWriteType); if (ok) { JSObject result = new JSObject(); result.put("bytesSent", payload.length); @@ -646,6 +637,7 @@ private void cleanupGatt() { bluetoothGatt = null; writeCharacteristic = null; writeNoResponseSupported = false; + cachedWriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; connectedDeviceId = null; servicesDiscovered = false; } @@ -948,6 +940,7 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { connectionState.set(ConnectionState.DISCONNECTED); cleanupGatt(); + // notifyConnectionState("disconnected", null); failConnect(status == BluetoothGatt.GATT_SUCCESS ? "Disconnected" : "Connect status: " + status); } } @@ -959,33 +952,29 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { writeCharacteristic = null; writeNoResponseSupported = false; + cachedWriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; try { for (BluetoothGattService svc : gatt.getServices()) { - BluetoothGattCharacteristic preferred = null; - BluetoothGattCharacteristic fallback = null; - + BluetoothGattCharacteristic preferred = null, fallback = null; for (BluetoothGattCharacteristic ch : svc.getCharacteristics()) { - final int props = ch.getProperties(); - if ((props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { - preferred = ch; // best option - break; - } - if (fallback == null && (props & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) { - fallback = ch; - } + int props = ch.getProperties(); + if ((props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { preferred = ch; break; } + if (fallback == null && (props & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) fallback = ch; } - - final BluetoothGattCharacteristic chosen = (preferred != null) ? preferred : fallback; + BluetoothGattCharacteristic chosen = (preferred != null) ? preferred : fallback; if (chosen != null) { writeCharacteristic = chosen; writeNoResponseSupported = - (chosen.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; + (chosen.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0; + cachedWriteType = writeNoResponseSupported + ? BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + : BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; break; } } } catch (Exception ignored) { - // leave writeCharacteristic null; write() will report unavailable + // leave writeCharacteristic null; write() will reject } flushPendingStartNotificationCalls(); diff --git a/src/js/msp/MSPHelper.js b/src/js/msp/MSPHelper.js index fe6c7776691..dbc38fe703d 100644 --- a/src/js/msp/MSPHelper.js +++ b/src/js/msp/MSPHelper.js @@ -2933,30 +2933,49 @@ MspHelper.prototype.sendSerialConfig = function (callback) { }; MspHelper.prototype.writeConfiguration = function (reboot, callback) { - if (reboot) { - GUI.interval_kill_all(); // stop status_pull and other tab timers immediately - MSP.callbacks_cleanup(); // drop any stale callbacks + // Quiet background traffic to avoid queue starvation on BLE + try { + GUI.interval_kill_all(); + MSP.callbacks_cleanup(); + } catch (e) { + console.warn("writeConfiguration: pre-save quieting failed:", e); } - // We need some protection when testing motors on motors tab - if (!FC.CONFIG.armingDisabled) { - this.setArmingEnabled(false, false); - } + const sendEeprom = () => { + let finished = false; + const finish = () => { + if (finished) return; + finished = true; - setTimeout(function () { - MSP.send_message(MSPCodes.MSP_EEPROM_WRITE, false, false, function () { - gui_log(i18n.getMessage("configurationEepromSaved")); - console.log("Configuration saved to EEPROM"); if (reboot) { GUI.tab_switch_cleanup(function () { return reinitializeConnection(); }); } - if (callback) { - callback(); - } + if (callback) callback(); + }; + + // Fallback in case the EEPROM ack is missed under BLE load + const fallbackTimer = setTimeout(() => { + console.warn("MSP_EEPROM_WRITE ack timeout; proceeding to reboot via fallback."); + finish(); + }, 3000); // conservative for Android BLE + + MSP.send_message(MSPCodes.MSP_EEPROM_WRITE, false, false, function () { + clearTimeout(fallbackTimer); + gui_log(i18n.getMessage("configurationEepromSaved")); + console.log("Configuration saved to EEPROM"); + finish(); }); - }, 200); // 200ms delay before sending MSP_EEPROM_WRITE to ensure that all settings have been received + }; + + // Keep your arming safety, but don’t block on it + if (!FC.CONFIG.armingDisabled) { + this.setArmingEnabled(false, false); + setTimeout(sendEeprom, 200); // current delay retained + } else { + setTimeout(sendEeprom, 200); + } }; let mspHelper; From 182da54959122246341c58d227735b2beeaf034e Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Tue, 2 Dec 2025 21:40:15 +0100 Subject: [PATCH 17/17] Guard BLE scan start against SecurityException to avoid process crash --- .../bluetooth/BetaflightBluetoothPlugin.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java index 16bf98cae18..af31fdfb3cb 100644 --- a/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java @@ -492,10 +492,19 @@ public void onScanFailed(int errorCode) { } }; - bluetoothLeScanner.startScan(filters.isEmpty() ? null : filters, settings, activeScanCallback); - - scanTimeoutRunnable = () -> rejectPendingScan("Scan timed out"); - mainHandler.postDelayed(scanTimeoutRunnable, timeoutMs); + try { + bluetoothLeScanner.startScan( + filters.isEmpty() ? null : filters, + settings, + activeScanCallback + ); + } catch (SecurityException ex) { + Log.e(TAG, "Bluetooth LE scan failed due to missing permissions", ex); + rejectPendingScan("Bluetooth scan permission denied"); + return; + } + scanTimeoutRunnable = () -> rejectPendingScan("Scan timed out"); + mainHandler.postDelayed(scanTimeoutRunnable, timeoutMs); } private void handleScanResult(ScanResult result, ScanCriteria criteria) {