diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 77bd517baf9..80a47f814db 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -56,6 +56,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
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..af31fdfb3cb
--- /dev/null
+++ b/android/app/src/main/java/betaflight/configurator/protocols/bluetooth/BetaflightBluetoothPlugin.java
@@ -0,0 +1,1094 @@
+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.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+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.
+ */
+@CapacitorPlugin(
+ name = "BetaflightBluetooth",
+ permissions = {
+ @Permission(
+ alias = "bluetooth",
+ strings = {}
+ )
+ }
+)
+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;
+
+ // 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;
+
+ @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) {
+ // 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;
+ }
+
+ // Store the call for later resolution
+ pendingPermissionCall = call;
+
+ // Request permissions using Activity's native method
+ ActivityCompat.requestPermissions(
+ getActivity(),
+ requiredPermissions,
+ BLUETOOTH_PERMISSION_REQUEST_CODE
+ );
+ }
+
+ private static final int BLUETOOTH_PERMISSION_REQUEST_CODE = 9002;
+ private PluginCall pendingPermissionCall;
+
+ 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
+ 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;
+ 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; }
+
+ final String value = call.getString("value", call.getString("data"));
+ final String encoding = call.getString("encoding", "base64");
+ final byte[] payload;
+ try { payload = decodePayload(value, encoding); }
+ catch (IllegalArgumentException ex) { call.reject("Failed to decode payload: " + ex.getMessage()); return; }
+
+ final boolean ok = submitWrite(gatt, target, payload, cachedWriteType);
+ if (ok) {
+ JSObject result = new JSObject();
+ result.put("bytesSent", payload.length);
+ call.resolve(result);
+ } else {
+ call.reject("Unable to write characteristic");
+ }
+ }
+
+ @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);
+ }
+ };
+
+ 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) {
+ 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;
+ writeCharacteristic = null;
+ writeNoResponseSupported = false;
+ cachedWriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT;
+ 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;
+ }
+ PluginCall pendingCall;
+ while ((pendingCall = pendingStartNotificationCalls.poll()) != null) {
+ startNotificationsInternal(pendingCall);
+ }
+ }
+
+ private void rejectPendingStartNotifications(String reason) {
+ if (pendingStartNotificationCalls.isEmpty()) {
+ return;
+ }
+ PluginCall pendingCall;
+ while ((pendingCall = pendingStartNotificationCalls.poll()) != null) {
+ 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() now called after service discovery
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ connectionState.set(ConnectionState.DISCONNECTED);
+ cleanupGatt();
+ // notifyConnectionState("disconnected", null);
+ 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;
+
+ writeCharacteristic = null;
+ writeNoResponseSupported = false;
+ cachedWriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT;
+
+ try {
+ for (BluetoothGattService svc : gatt.getServices()) {
+ BluetoothGattCharacteristic preferred = null, fallback = null;
+ for (BluetoothGattCharacteristic ch : svc.getCharacteristics()) {
+ 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;
+ }
+ BluetoothGattCharacteristic chosen = (preferred != null) ? preferred : fallback;
+ if (chosen != null) {
+ writeCharacteristic = chosen;
+ writeNoResponseSupported =
+ (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 reject
+ }
+
+ flushPendingStartNotificationCalls();
+ logGattLayout("Services discovered", gatt);
+ JSArray services = new JSArray();
+ for (BluetoothGattService service : gatt.getServices()) {
+ 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);
+ 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;
+ 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;
+ }
+ 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/msp/MSPHelper.js b/src/js/msp/MSPHelper.js
index f175d049507..dbc38fe703d 100644
--- a/src/js/msp/MSPHelper.js
+++ b/src/js/msp/MSPHelper.js
@@ -2933,25 +2933,49 @@ MspHelper.prototype.sendSerialConfig = function (callback) {
};
MspHelper.prototype.writeConfiguration = function (reboot, callback) {
- // We need some protection when testing motors on motors tab
- if (!FC.CONFIG.armingDisabled) {
- this.setArmingEnabled(false, false);
+ // 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);
}
- setTimeout(function () {
- MSP.send_message(MSPCodes.MSP_EEPROM_WRITE, false, false, function () {
- gui_log(i18n.getMessage("configurationEepromSaved"));
- console.log("Configuration saved to EEPROM");
+ const sendEeprom = () => {
+ let finished = false;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+
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();
});
- }, 100); // 100ms 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;
diff --git a/src/js/protocols/CapacitorBluetooth.js b/src/js/protocols/CapacitorBluetooth.js
new file mode 100644
index 00000000000..b775ead9d44
--- /dev/null
+++ b/src/js/protocols/CapacitorBluetooth.js
@@ -0,0 +1,652 @@
+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 GATT_PROPERTIES = {
+ READ: 0x02,
+ WRITE_NO_RESPONSE: 0x04,
+ WRITE: 0x08,
+ NOTIFY: 0x10,
+ INDICATE: 0x20,
+};
+
+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.notificationActive = false;
+ 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);
+
+ this.attachNativeListeners();
+ }
+
+ handleNewDevice(devicePayload) {
+ const resolvedDevice = devicePayload?.device ?? devicePayload;
+
+ if (!resolvedDevice?.uuids?.[0]) {
+ console.error(`${this.logHead} Error: Could not resolve device UUID`, 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;
+
+ 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 {
+ const options = { acceptAllDevices: true }; // Android workaround
+ 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);
+ } catch (error) {
+ console.error(`${logHead} User didn't select any Bluetooth device when requesting permission:`, error);
+ }
+ console.log(
+ `${logHead} Permission device after filtering:`,
+ newPermissionDevice,
+ newPermissionDevice?.device?.uuids,
+ );
+ return newPermissionDevice?.device?.uuids?.length ? newPermissionDevice : null;
+ }
+
+ 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) ?? null;
+ if (!deviceDescription) {
+ console.warn(
+ `${logHead} No static profile for device ${requestedDevice.device?.name ?? requestedDevice.path}`,
+ );
+ }
+
+ 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);
+ });
+
+ 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();
+
+ 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(requestedDevice.device?.deviceId ?? null);
+ 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 ?? null;
+
+ 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.device || !this.deviceDescription?.writeCharacteristic) {
+ 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;
+ const activeDeviceId = deviceId ?? this.device?.device?.deviceId ?? null;
+
+ 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(activeDeviceId);
+ this.dispatchEvent(new CustomEvent("disconnect", { detail: true }));
+ }
+
+ cleanupConnectionState(deviceId = null) {
+ 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;
+ 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() {
+ 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 ?? null);
+ }
+ });
+
+ registerListener("services", (event) => {
+ this.handleServicesEvent(event);
+ });
+ }
+}
+
+export default CapacitorBluetooth;
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/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
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()) {