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()) {