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
This commit is contained in:
Benjamin Temple
2026-04-12 09:36:25 -07:00
parent bec7846657
commit 58f0676c0e
6 changed files with 505 additions and 59 deletions
@@ -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();
@@ -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);
@@ -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 <https://www.gnu.org/licenses/>. */
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);
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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;
}
}
@@ -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();
@@ -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 {