From 58f0676c0eccbfdcda2091f8fb3b60081e3bc1f2 Mon Sep 17 00:00:00 2001 From: Benjamin Temple Date: Tue, 24 Mar 2026 21:34:50 -0700 Subject: [PATCH] Pebble: Fix Pebble [Core 2 Duo / Time 2 / 2 Round] BLE pairing Changes: - BondingUtil: Use PebbleHardware.isBleOnly() for accurate BLE detection - PebbleIoThread: Detect BLE-only devices using hardware revision/model - PebblePairingActivity: Use manufacturer data for device identification, refresh device list on pairing complete - PebbleGATTClient: Fixed connection handling when pairing with new pebble watches based on open source PebbleOS New status and pairing codes which match PebbleOS implementation. - ConnectivityStatus: BLE connectivity state enum - PairingErrorCode: Pairing error codes for better debugging --- .../devices/pebble/PebblePairingActivity.java | 37 +- .../devices/pebble/PebbleIoThread.java | 8 +- .../pebble/ble/ConnectivityStatus.java | 79 ++++ .../devices/pebble/ble/PairingErrorCode.java | 59 +++ .../devices/pebble/ble/PebbleGATTClient.java | 336 +++++++++++++++--- .../gadgetbridge/util/BondingUtil.java | 45 ++- 6 files changed, 505 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/ConnectivityStatus.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingErrorCode.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java index 7dcae46d3c..95010c446b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java @@ -46,6 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao; @@ -56,6 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface; import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware; public class PebblePairingActivity extends AbstractGBActivity implements BondingInterface { @@ -104,7 +106,7 @@ public class PebblePairingActivity extends AbstractGBActivity implements Bonding message.setText(getString(R.string.pairing, btDevice.getAddress())); GBDevice device; - if (BondingUtil.isLePebble(btDevice)) { + if (PebbleHardware.isLePebbleCompanion(btDevice)) { if (!GBApplication.getPrefs().getBoolean("pebble_force_le", false)) { GB.toast(this, "Please switch on \"Always prefer BLE\" option in Pebble settings before pairing you Pebble LE", Toast.LENGTH_LONG, GB.ERROR); onBondingComplete(false); @@ -122,6 +124,30 @@ public class PebblePairingActivity extends AbstractGBActivity implements Bonding return; } + // Pebble 2, Pebble Time 2, and Pebble 2 Duo are BLE-only and need GATT connection first. + // They require writing to a pairing trigger characteristic before createBond() works. + // PebbleGATTClient handles the pairing trigger write and initiates bonding. + // Note: These devices are BLE-only, so we don't need the "pebble_force_le" setting - + // PebbleIoThread automatically uses BLE based on isPebble2() detection. + // Use deviceCandidate to leverage manufacturer data for reliable device identification. + if (BondingUtil.needsConnectFirstPairing(deviceCandidate)) { + LOG.info("Pebble 2/Time 2/2 Duo detected via manufacturer data - using connect-first pairing (BLE-only device)"); + + registerBroadcastReceivers(); + + // Create device and initiate connection - BLE mode will be auto-detected in PebbleIoThread + GBDevice gbDevice = DeviceHelper.getInstance().toSupportedDevice(deviceCandidate); + if (gbDevice != null) { + GB.toast(this, getString(R.string.discovery_trying_to_connect_to, gbDevice.getName()), Toast.LENGTH_SHORT, GB.INFO); + GBApplication.deviceService(gbDevice).connect(true); + // Don't call onBondingComplete() here - wait for bonding via broadcast receiver + } else { + LOG.error("Failed to create GBDevice for Pebble 2/Time 2/2 Duo"); + onBondingComplete(false); + } + return; + } + if (btDevice.getBondState() == BluetoothDevice.BOND_BONDED || btDevice.getBondState() == BluetoothDevice.BOND_BONDING) { BondingUtil.connectThenComplete(this, deviceCandidate); @@ -171,6 +197,13 @@ public class PebblePairingActivity extends AbstractGBActivity implements Bonding public void onBondingComplete(boolean success) { LOG.debug("ONBONDINGCOMPLETE"); unregisterBroadcastReceivers(); + + // Trigger device list refresh so the new device appears immediately + if (success) { + LocalBroadcastManager.getInstance(this).sendBroadcast( + new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST)); + } + if (success) { startActivity(new Intent(this, ControlCenterv2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); } else { @@ -178,7 +211,7 @@ public class PebblePairingActivity extends AbstractGBActivity implements Bonding } // If it's not a LE Pebble, initiate a connection when bonding is complete - if (!BondingUtil.isLePebble(getCurrentTarget().getDevice()) && success) { + if (!PebbleHardware.isLePebbleCompanion(getCurrentTarget().getDevice()) && success) { BondingUtil.attemptToFirstConnect(getCurrentTarget().getDevice()); } finish(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java index 4eeaadf945..c4e77e0e05 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java @@ -64,6 +64,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.Pebbl import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceIoThread; import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware; +import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils; @@ -182,8 +183,11 @@ class PebbleIoThread extends GBDeviceIoThread { deviceAddress = gbDevice.getVolatileAddress(); } BluetoothDevice btDevice = mBtAdapter.getRemoteDevice(deviceAddress); - if (btDevice.getType() == BluetoothDevice.DEVICE_TYPE_LE) { - LOG.info("This is a Pebble 2 or Pebble-LE/Pebble Time LE, will use BLE"); + // Use BLE for DEVICE_TYPE_LE or for Pebble 2/Time 2/2 Duo which are DEVICE_TYPE_DUAL but BLE-only + // Check both btDevice and gbDevice (btDevice.getName() can be null during reconnection) + boolean isPebble2 = PebbleHardware.isBleOnly(gbDevice, btDevice); + if (btDevice.getType() == BluetoothDevice.DEVICE_TYPE_LE || isPebble2) { + LOG.info("This is a Pebble 2/Time 2/2 Duo or Pebble-LE/Pebble Time LE, will use BLE (isPebble2={})", isPebble2); mInStream = new PipedInputStream(); mOutStream = new PipedOutputStream(); mPebbleLESupport = new PebbleLESupport(this.getContext(), mPebbleSupport, gbDevice, btDevice, (PipedInputStream) mInStream, (PipedOutputStream) mOutStream); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/ConnectivityStatus.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/ConnectivityStatus.java new file mode 100644 index 0000000000..b6425e5a1c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/ConnectivityStatus.java @@ -0,0 +1,79 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +/** + * Parses the Pebble Connectivity characteristic to determine pairing state. + * Based on libpebble3 ConnectivityWatcher.kt + * + * The connectivity characteristic contains status flags indicating the current + * connection and pairing state from the watch's perspective. + */ +public class ConnectivityStatus { + public final boolean connected; + public final boolean paired; + public final boolean encrypted; + public final boolean hasBondedGateway; + public final boolean supportsPinningWithoutSlaveSecurity; + public final boolean hasRemoteAttemptedToUseStalePairing; + public final PairingErrorCode pairingErrorCode; + + /** + * Parse a connectivity characteristic value. + * + * @param characteristicValue Raw bytes from the connectivity characteristic. + * Byte 0: Status flags + * Byte 3: Pairing error code (if present) + */ + public ConnectivityStatus(byte[] characteristicValue) { + if (characteristicValue == null || characteristicValue.length == 0) { + // Default to safe values if no data + connected = false; + paired = false; + encrypted = false; + hasBondedGateway = false; + supportsPinningWithoutSlaveSecurity = false; + hasRemoteAttemptedToUseStalePairing = false; + pairingErrorCode = PairingErrorCode.UNKNOWN_ERROR; + return; + } + + byte flags = characteristicValue[0]; + connected = (flags & 0b000001) != 0; + paired = (flags & 0b000010) != 0; + encrypted = (flags & 0b000100) != 0; + hasBondedGateway = (flags & 0b001000) != 0; + supportsPinningWithoutSlaveSecurity = (flags & 0b010000) != 0; + hasRemoteAttemptedToUseStalePairing = (flags & 0b100000) != 0; + + if (characteristicValue.length > 3) { + pairingErrorCode = PairingErrorCode.fromValue(characteristicValue[3]); + } else { + pairingErrorCode = PairingErrorCode.NO_ERROR; + } + } + + @Override + public String toString() { + return String.format( + "ConnectivityStatus{connected=%b, paired=%b, encrypted=%b, " + + "hasBondedGateway=%b, supportsPinning=%b, stalePairing=%b, errorCode=%s}", + connected, paired, encrypted, hasBondedGateway, + supportsPinningWithoutSlaveSecurity, hasRemoteAttemptedToUseStalePairing, + pairingErrorCode); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingErrorCode.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingErrorCode.java new file mode 100644 index 0000000000..2d8043e2f3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingErrorCode.java @@ -0,0 +1,59 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +/** + * Pairing error codes from the Pebble Connectivity characteristic. + * Based on libpebble3 PairingErrorCode enum. + */ +public enum PairingErrorCode { + NO_ERROR((byte) 0), + PASSKEY_ENTRY_FAILED((byte) 1), + OOB_NOT_AVAILABLE((byte) 2), + AUTHENTICATION_REQUIREMENTS((byte) 3), + CONFIRM_VALUE_FAILED((byte) 4), + PAIRING_NOT_SUPPORTED((byte) 5), + ENCRYPTION_KEY_SIZE((byte) 6), + COMMAND_NOT_SUPPORTED((byte) 7), + UNSPECIFIED_REASON((byte) 8), + REPEATED_ATTEMPTS((byte) 9), + INVALID_PARAMETERS((byte) 10), + DHKEY_CHECK_FAILED((byte) 11), + NUMERIC_COMPARISON_FAILED((byte) 12), + BR_EDR_PAIRING_IN_PROGRESS((byte) 13), + CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED((byte) 14), + UNKNOWN_ERROR((byte) -1); + + private final byte value; + + PairingErrorCode(byte value) { + this.value = value; + } + + public byte getValue() { + return value; + } + + public static PairingErrorCode fromValue(byte value) { + for (PairingErrorCode code : values()) { + if (code.value == value) { + return code; + } + } + return UNKNOWN_ERROR; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java index 5fa83ad6ec..ad85534ee5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java @@ -26,7 +26,14 @@ import android.bluetooth.BluetoothGattCallback; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; import android.bluetooth.BluetoothGattService; +import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Looper; + +import androidx.core.content.ContextCompat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,6 +78,12 @@ class PebbleGATTClient extends BluetoothGattCallback { private CountDownLatch mWaitWriteCompleteLatch; + // New pairing state management + private ConnectivityStatus mConnectivityStatus; + private CountDownLatch mBondingLatch; + private BroadcastReceiver mBondingReceiver; + private static final long BONDING_TIMEOUT_MS = 60000; // 60 seconds + PebbleGATTClient(PebbleLESupport pebbleLESupport, Context context, BluetoothDevice btDevice) { mContext = context; mPebbleLESupport = pebbleLESupport; @@ -106,17 +119,20 @@ class PebbleGATTClient extends BluetoothGattCallback { LOG.info("onCharacteristicRead() status = {}", status); if (status == BluetoothGatt.GATT_SUCCESS) { LOG.info("onCharacteristicRead() {} {}", characteristic.getUuid().toString(), GB.hexdump(characteristic.getValue(), 0, -1)); - if (characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { + + if (characteristic.getUuid().equals(CONNECTIVITY_CHARACTERISTIC)) { + // This is required for Pebble Time 2 (EMERY) pairing + handleConnectivityRead(gatt, characteristic.getValue()); + } else if (characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { int battery_percent = ValueDecoder.decodePercent(characteristic, characteristic.getValue()); LOG.info("Got battery level through read, is at {}%", battery_percent); GBDeviceEventBatteryInfo gbDeviceEventBatteryInfo = new GBDeviceEventBatteryInfo(); gbDeviceEventBatteryInfo.level = battery_percent; gbDeviceEventBatteryInfo.state = BatteryState.BATTERY_NORMAL; mPebbleLESupport.getPebbleSupport().evaluateGBDeviceEvent(gbDeviceEventBatteryInfo); - } else if ((characteristic.getUuid().equals(PAIRING_TRIGGER_CHARACTERISTIC))) { - // this is just a hack to force sequential ble commands for initialization - // kind of event driven - // And this never happens when not READING the pairing trigger which is only done for old pebbles running fw 3.x + } else if (characteristic.getUuid().equals(PAIRING_TRIGGER_CHARACTERISTIC)) { + // Legacy path: this is just a hack to force sequential ble commands for initialization + // Only happens when READING the pairing trigger for old pebbles running fw 3.x if (hasConnectivityCharacteristics) { subscribeToConnectivity(gatt); } else { @@ -155,9 +171,13 @@ class PebbleGATTClient extends BluetoothGattCallback { } else { LOG.warn("mWaitWriteCompleteLatch is null!"); } - } else if (characteristic.getUuid().equals(PAIRING_TRIGGER_CHARACTERISTIC) || characteristic.getUuid().equals(CONNECTIVITY_CHARACTERISTIC)) { - //mBtDevice.createBond(); // did not work when last tried - + } else if (characteristic.getUuid().equals(PAIRING_TRIGGER_CHARACTERISTIC)) { + // Pairing trigger written - now initiate Bluetooth bonding + // This is required for Pebble Time 2 (EMERY) pairing + LOG.info("Pairing trigger written successfully (status={}), initiating bond", status); + initiateBluetoothBond(gatt); + } else if (characteristic.getUuid().equals(CONNECTIVITY_CHARACTERISTIC)) { + // Legacy path for connectivity characteristic write if (hasConnectivityCharacteristics) { subscribeToConnectivity(gatt); } else { @@ -202,41 +222,44 @@ class PebbleGATTClient extends BluetoothGattCallback { } LOG.info("onServicesDiscovered() status = {}", status); - if (status == BluetoothGatt.GATT_SUCCESS) { - BluetoothGattCharacteristic connectionParamCharacteristic = gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC); - hasConnectivityCharacteristics = connectionParamCharacteristic == null; + if (status != BluetoothGatt.GATT_SUCCESS) { + LOG.error("Service discovery failed with status {}", status); + return; + } - if (hasConnectivityCharacteristics) { - LOG.info("This seems to be an older le enabled Pebble (Pebble Time), or a 2025 Pebble"); - } + BluetoothGattService pairingService = gatt.getService(SERVICE_UUID); + if (pairingService == null) { + LOG.error("Pairing service not found"); + return; + } - if (doPairing) { - BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(PAIRING_TRIGGER_CHARACTERISTIC); - if ((characteristic.getProperties() & PROPERTY_WRITE) != 0) { - LOG.info("This seems to be a >=4.0 FW Pebble, writing to pairing trigger"); - // flags: - // 0 - always 1 - // 1 - unknown - // 2 - always 0 - // 3 - unknown, set on kitkat (seems to help to get a "better" pairing) - // 4 - unknown, set on some phones - byte[] value; - if (mPebbleLESupport.clientOnly) { - value = new byte[]{0x11}; // needed in clientOnly mode (TODO: try 0x19) - } else { - value = new byte[]{0x09}; // I just keep this, because it worked - } - WriteAction.writeCharacteristic(gatt, characteristic, value); - } else { - LOG.info("This seems to be some <4.0 FW Pebble, reading pairing trigger"); - gatt.readCharacteristic(characteristic); - } + BluetoothGattCharacteristic connectionParamCharacteristic = + pairingService.getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC); + hasConnectivityCharacteristics = connectionParamCharacteristic == null; + + if (hasConnectivityCharacteristics) { + LOG.info("This seems to be an older LE Pebble (Pebble Time), or a 2025 Pebble"); + } + + if (doPairing) { + // Read Connectivity characteristic FIRST to determine pairing state + // This is required for Pebble Time 2 (EMERY) pairing + BluetoothGattCharacteristic connectivityChar = + pairingService.getCharacteristic(CONNECTIVITY_CHARACTERISTIC); + if (connectivityChar != null) { + LOG.info("Reading connectivity characteristic to check pairing state"); + gatt.readCharacteristic(connectivityChar); + // Continue in onCharacteristicRead() -> handleConnectivityRead() } else { - if (hasConnectivityCharacteristics) { - subscribeToConnectivity(gatt); - } else { - subscribeToConnectionParams(gatt); - } + // Fallback for very old firmware - use legacy method + LOG.warn("Connectivity characteristic not found, using legacy pairing"); + proceedWithLegacyPairing(gatt); + } + } else { + if (hasConnectivityCharacteristics) { + subscribeToConnectivity(gatt); + } else { + subscribeToConnectionParams(gatt); } } } @@ -354,7 +377,240 @@ class PebbleGATTClient extends BluetoothGattCallback { mWaitWriteCompleteLatch = null; } + // ================== New Pairing Methods (based on libpebble3) ================== + + /** + * Handle the connectivity characteristic read to determine pairing state. + * Based on libpebble3 ConnectivityWatcher and PebblePairing. + */ + private void handleConnectivityRead(BluetoothGatt gatt, byte[] value) { + mConnectivityStatus = new ConnectivityStatus(value); + LOG.info("Connectivity status: {}", mConnectivityStatus); + + BluetoothDevice device = gatt.getDevice(); + int bondState = device.getBondState(); + boolean phoneBonded = bondState == BluetoothDevice.BOND_BONDED; + LOG.info("Phone bond state: {} (BONDED={})", bondState, BluetoothDevice.BOND_BONDED); + + // If BOTH sides are already paired, skip pairing trigger and bonding entirely + if (mConnectivityStatus.paired && phoneBonded) { + LOG.info("Already paired on both sides - skipping pairing trigger, proceeding with subscriptions"); + proceedAfterPairing(gatt); + return; + } + + LOG.info("Proceeding with pairing trigger write (watch.paired={}, phone.bonded={})", + mConnectivityStatus.paired, phoneBonded); + + BluetoothGattCharacteristic pairingTrigger = + gatt.getService(SERVICE_UUID).getCharacteristic(PAIRING_TRIGGER_CHARACTERISTIC); + + if (pairingTrigger == null) { + LOG.error("Pairing trigger characteristic not found"); + proceedAfterPairing(gatt); + return; + } + + if ((pairingTrigger.getProperties() & PROPERTY_WRITE) != 0) { + LOG.info("Writing pairing trigger for Pebble Time 2 / modern Pebble"); + + // Use the original working values, but we'll add createBond() after + // 0x09 = pinAddress(1) + autoAccept(8) - worked for normal mode + // 0x11 = pinAddress(1) + watchAsServer(16) - for clientOnly mode + byte[] triggerValue; + if (mPebbleLESupport.clientOnly) { + triggerValue = new byte[]{0x11}; + LOG.info("Using clientOnly pairing trigger: 0x11"); + } else { + triggerValue = new byte[]{0x09}; + LOG.info("Using normal pairing trigger: 0x09"); + } + WriteAction.writeCharacteristic(gatt, pairingTrigger, triggerValue); + // Continue in onCharacteristicWrite() -> initiateBluetoothBond() + } else { + // Old firmware - just read the characteristic (legacy path) + LOG.info("Pairing trigger not writable, using legacy read"); + gatt.readCharacteristic(pairingTrigger); + } + } + + /** + * Initiate Bluetooth bonding after writing the pairing trigger. + * Based on libpebble3 PebblePairing.requestBlePairing(). + */ + private void initiateBluetoothBond(BluetoothGatt gatt) { + BluetoothDevice device = gatt.getDevice(); + int currentBondState = device.getBondState(); + LOG.info("Current bond state before pairing: {} (NONE={}, BONDING={}, BONDED={})", + currentBondState, BluetoothDevice.BOND_NONE, + BluetoothDevice.BOND_BONDING, BluetoothDevice.BOND_BONDED); + + // If already bonded, just proceed - don't try to re-bond + if (currentBondState == BluetoothDevice.BOND_BONDED) { + LOG.info("Device already bonded, proceeding with subscriptions"); + proceedAfterPairing(gatt); + return; + } + + LOG.info("Initiating Bluetooth bond with {}ms timeout", BONDING_TIMEOUT_MS); + + // Register bond state receiver + mBondingLatch = new CountDownLatch(1); + mBondingReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + BluetoothDevice bondDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (bondDevice == null || !bondDevice.getAddress().equals(device.getAddress())) { + return; + } + int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE); + LOG.info("Bond state changed: {}", bondState); + + if (bondState == BluetoothDevice.BOND_BONDED) { + LOG.info("Bonding succeeded!"); + mBondingLatch.countDown(); + } else if (bondState == BluetoothDevice.BOND_NONE) { + int reason = intent.getIntExtra("android.bluetooth.device.extra.REASON", -1); + LOG.error("Bonding failed with reason: {}", reason); + mBondingLatch.countDown(); + } + } + }; + + IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED); + ContextCompat.registerReceiver(mContext, mBondingReceiver, filter, ContextCompat.RECEIVER_EXPORTED); + + // Start bonding on background thread to avoid blocking GATT callbacks + new Thread(() -> { + try { + LOG.info("Calling createBond() on device {}", device.getAddress()); + boolean bondInitiated = device.createBond(); + LOG.info("createBond() returned: {}", bondInitiated); + + if (!bondInitiated) { + LOG.error("Failed to initiate bonding - createBond() returned false"); + LOG.info("Current bond state after failed createBond: {}", device.getBondState()); + cleanupBondingReceiver(); + // Still proceed to try connection + new Handler(Looper.getMainLooper()).post(() -> proceedAfterPairing(gatt)); + return; + } + + LOG.info("Waiting for bond state change (timeout: {}ms)...", BONDING_TIMEOUT_MS); + boolean bonded = mBondingLatch.await(BONDING_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS); + int finalBondState = device.getBondState(); + LOG.info("Bond wait completed: latch={}, finalState={}", bonded, finalBondState); + + if (bonded && finalBondState == BluetoothDevice.BOND_BONDED) { + LOG.info("Bonding completed successfully!"); + } else { + LOG.warn("Bonding timed out or failed (latch={}, state={}), proceeding anyway", + bonded, finalBondState); + } + } catch (InterruptedException e) { + LOG.error("Bonding interrupted", e); + } catch (SecurityException e) { + LOG.error("SecurityException during createBond - missing BLUETOOTH_CONNECT permission?", e); + } catch (Exception e) { + LOG.error("Unexpected error during bonding", e); + } finally { + cleanupBondingReceiver(); + // Continue with subscription chain on main thread + new Handler(Looper.getMainLooper()).post(() -> proceedAfterPairing(gatt)); + } + }).start(); + } + + /** + * Clean up the bonding broadcast receiver. + */ + private void cleanupBondingReceiver() { + if (mBondingReceiver != null) { + try { + mContext.unregisterReceiver(mBondingReceiver); + } catch (Exception e) { + LOG.warn("Error unregistering bonding receiver: {}", e.getMessage()); + } + mBondingReceiver = null; + } + } + + /** + * Continue with the subscription chain after pairing is complete or skipped. + */ + private void proceedAfterPairing(BluetoothGatt gatt) { + LOG.info("Proceeding after pairing to subscription chain"); + if (hasConnectivityCharacteristics) { + subscribeToConnectivity(gatt); + } else { + subscribeToConnectionParams(gatt); + } + } + + /** + * Legacy pairing path for backward compatibility with older firmwares. + */ + private void proceedWithLegacyPairing(BluetoothGatt gatt) { + LOG.info("Using legacy pairing method"); + BluetoothGattCharacteristic characteristic = + gatt.getService(SERVICE_UUID).getCharacteristic(PAIRING_TRIGGER_CHARACTERISTIC); + if ((characteristic.getProperties() & PROPERTY_WRITE) != 0) { + byte[] value = mPebbleLESupport.clientOnly ? new byte[]{0x11} : new byte[]{0x09}; + WriteAction.writeCharacteristic(gatt, characteristic, value); + } else { + gatt.readCharacteristic(characteristic); + } + } + + /** + * Determines if we need to pair based on connectivity status and bond state. + * Based on libpebble3 PebbleBle.kt pairing logic. + */ + private boolean shouldPair(ConnectivityStatus status, boolean phoneBonded) { + if (status.paired && phoneBonded) { + LOG.info("Already paired on both sides, skipping pairing"); + return false; + } + if (status.paired && !phoneBonded) { + LOG.info("Watch thinks it's paired, phone does not - need to re-pair"); + return true; + } + if (!status.paired && phoneBonded) { + LOG.info("Phone thinks it's paired, watch does not - need to re-pair"); + return true; + } + LOG.info("Neither side paired - need full pairing"); + return true; + } + + /** + * Builds the pairing trigger value with proper bit flags. + * Based on libpebble3 PebblePairing.makePairingTriggerValue() + * + * Bit 0: pinAddress - pin BLE address to prevent MAC rotation + * Bit 1: noSecurityRequest - if true, watch won't request security (phone will via createBond) + * Bit 2: forceSecurityRequest - if true, watch will request security (inverse of bit 1) + * Bit 3: autoAcceptFuturePairing + * Bit 4: watchAsGattServer - for reversed PPoG mode + * + * When phone calls createBond(), set noSecurityRequest=true so watch doesn't also request. + */ + private byte buildPairingTriggerValue(boolean pinAddress, boolean noSecurityRequest, + boolean autoAcceptFuture, boolean watchAsGattServer) { + byte value = 0; + if (pinAddress) value |= 0b00001; // Bit 0 + if (noSecurityRequest) value |= 0b00010; // Bit 1 + if (!noSecurityRequest) value |= 0b00100; // Bit 2: force security (only if bit 1 is 0) + if (autoAcceptFuture) value |= 0b01000; // Bit 3 + if (watchAsGattServer) value |= 0b10000; // Bit 4 + LOG.debug("buildPairingTriggerValue: pin={}, noSec={}, auto={}, server={} -> 0x{}", + pinAddress, noSecurityRequest, autoAcceptFuture, watchAsGattServer, + String.format("%02X", value)); + return value; + } + public void close() { + cleanupBondingReceiver(); if (mBluetoothGatt != null) { mBluetoothGatt.disconnect(); mBluetoothGatt.close(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java index 09a4b89b63..c9c502056f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java @@ -67,6 +67,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; import nodomain.freeyourgadget.gadgetbridge.service.btle.BleNamesResolver; +import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware; @SuppressLint("MissingPermission") public class BondingUtil { @@ -134,9 +135,12 @@ public class BondingUtil { switch (bondState) { case BluetoothDevice.BOND_BONDED: { LOG.info("Bonded with {}", device.getAddress()); - //noinspection StatementWithEmptyBody - if (isLePebble(device) || isPebble2(device) || !bondingInterface.getAttemptToConnect()) { - // Do not initiate connection to LE Pebble and some others! + if (PebbleHardware.isLePebbleCompanion(device) || PebbleHardware.isBleOnly(device) || !bondingInterface.getAttemptToConnect()) { + // Do not initiate connection to LE Pebble and Pebble 2/Time 2/2 Duo + // For Pebble 2/Time 2/2 Duo, the connection was already started before bonding + // Just signal that bonding is complete + LOG.info("LE/Pebble2/Pebble2Duo device bonded - signaling completion"); + bondingInterface.onBondingComplete(true); } else { attemptToFirstConnect(device); } @@ -331,20 +335,31 @@ public class BondingUtil { } /** - * Checks if device is LE Pebble + * Checks if device needs connect-first pairing (GATT connection before createBond). + * Pebble 2, Pebble Time 2, and Pebble 2 Duo require writing to a pairing trigger + * characteristic before Android's createBond() will succeed. + * + * @param candidate Device candidate with manufacturer data from BLE scan + * @return true if this device needs connect-first pairing */ - public static boolean isLePebble(BluetoothDevice device) { - return (device.getType() == BluetoothDevice.DEVICE_TYPE_DUAL || device.getType() == BluetoothDevice.DEVICE_TYPE_LE) && - (device.getName().startsWith("Pebble-LE ") || device.getName().startsWith("Pebble Time LE ")); + public static boolean needsConnectFirstPairing(GBDeviceCandidate candidate) { + if (candidate == null) { + return false; + } + // First try manufacturer data - this is the most reliable method + if (PebbleHardware.isBleOnly(candidate.getManufacturerSpecificData())) { + return true; + } + // Fall back to name-based detection + return PebbleHardware.isBleOnly(candidate.getDevice()); } /** - * Checks if device is Pebble 2 + * Checks if device needs connect-first pairing (GATT connection before createBond). + * Uses name-based detection when GBDeviceCandidate is not available. */ - public static boolean isPebble2(BluetoothDevice device) { - return device.getType() == BluetoothDevice.DEVICE_TYPE_LE && - device.getName().startsWith("Pebble ") && - !device.getName().startsWith("Pebble Time LE "); + public static boolean needsConnectFirstPairing(BluetoothDevice device) { + return PebbleHardware.isBleOnly(device); } /** @@ -445,7 +460,7 @@ public class BondingUtil { if (bondState == BluetoothDevice.BOND_BONDED) { GB.toast(bondingInterface.getContext().getString(R.string.pairing_already_bonded, device.getName(), device.getAddress()), Toast.LENGTH_SHORT, GB.INFO); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isPebble2(device) && contextIsActivity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !PebbleHardware.isBleOnly(device) && contextIsActivity) { // If CompanionDeviceManager is available, skip connection and go bond // TODO: It would theoretically be nice to check if it's already been granted, // but re-bond works @@ -458,9 +473,9 @@ public class BondingUtil { GB.toast(bondingInterface.getContext(), bondingInterface.getContext().getString(R.string.pairing_creating_bond_with, device.getName(), device.getAddress()), Toast.LENGTH_LONG, GB.INFO); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isPebble2(device) && contextIsActivity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !PebbleHardware.isBleOnly(device) && contextIsActivity) { askCompanionPairing(bondingInterface, device); - } else if (isPebble2(device)) { + } else if (PebbleHardware.isBleOnly(device)) { // TODO: start companionDevicePairing after connecting to Pebble 2 but before writing to pairing trigger attemptToFirstConnect(device); } else {