Pebble: Rewrite pairing flow to be more cleanly structured

This commit is contained in:
Benjamin Temple
2026-04-12 09:36:31 -07:00
parent 58f0676c0e
commit 536d68875f
7 changed files with 718 additions and 319 deletions
@@ -0,0 +1,35 @@
/* 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;
import android.bluetooth.BluetoothGatt;
/**
* Callback interface for pairing flow completion.
* Allows pairing flows to notify PebbleGATTClient when pairing is complete
* and the appropriate subscription chain should begin.
*/
interface PairingCallback {
/**
* Called when pairing is complete (or skipped) and subscription chain should start.
*
* @param gatt The GATT connection
* @param hasConnectivityCharacteristics true if device has connectivity characteristic (modern),
* false if using connection parameters characteristic (legacy)
*/
void onPairingComplete(BluetoothGatt gatt, boolean hasConnectivityCharacteristics);
}
@@ -17,7 +17,6 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble;
import static android.bluetooth.BluetoothGattCharacteristic.FORMAT_UINT16;
import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothDevice;
@@ -26,14 +25,7 @@ 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;
@@ -51,21 +43,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.ValueDecoder;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@SuppressLint("MissingPermission")
class PebbleGATTClient extends BluetoothGattCallback {
class PebbleGATTClient extends BluetoothGattCallback implements PairingCallback {
private static final Logger LOG = LoggerFactory.getLogger(PebbleGATTClient.class);
private static final UUID SERVICE_UUID = UUID.fromString("0000fed9-0000-1000-8000-00805f9b34fb");
private static final UUID CONNECTIVITY_CHARACTERISTIC = UUID.fromString("00000001-328E-0FBB-C642-1AA6699BDADA");
private static final UUID PAIRING_TRIGGER_CHARACTERISTIC = UUID.fromString("00000002-328E-0FBB-C642-1AA6699BDADA");
private static final UUID MTU_CHARACTERISTIC = UUID.fromString("00000003-328e-0fbb-c642-1aa6699bdada");
private static final UUID CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.fromString("00000005-328E-0FBB-C642-1AA6699BDADA");
private static final UUID CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
//PPoGATT service (Pebble side)
private static final UUID PPOGATT_SERVICE_UUID = UUID.fromString("30000003-328E-0FBB-C642-1AA6699BDADA");
private static final UUID PPOGATT_CHARACTERISTIC_READ = UUID.fromString("30000004-328E-0FBB-C642-1AA6699BDADA");
private static final UUID PPOGATT_CHARACTERISTIC_WRITE = UUID.fromString("30000006-328E-0FBB-C642-1AA6699BDADA");
private BluetoothGattCharacteristic writeCharacteristics;
@@ -73,16 +54,12 @@ class PebbleGATTClient extends BluetoothGattCallback {
private final PebbleLESupport mPebbleLESupport;
private boolean hasConnectivityCharacteristics = false;
private final boolean doPairing = true;
private BluetoothGatt mBluetoothGatt;
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
// Pairing flow strategy
private PebblePairingFlow mPairingFlow;
PebbleGATTClient(PebbleLESupport pebbleLESupport, Context context, BluetoothDevice btDevice) {
mContext = context;
@@ -96,11 +73,11 @@ class PebbleGATTClient extends BluetoothGattCallback {
return;
}
if (characteristic.getUuid().equals(MTU_CHARACTERISTIC)) {
if (characteristic.getUuid().equals(PebbleGATTConstants.MTU_CHARACTERISTIC)) {
int newMTU = characteristic.getIntValue(FORMAT_UINT16, 0);
LOG.info("Pebble requested MTU: {}", newMTU);
mPebbleLESupport.setMTU(newMTU);
} else if (characteristic.getUuid().equals(PPOGATT_CHARACTERISTIC_READ)) {
} else if (characteristic.getUuid().equals(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ)) {
mPebbleLESupport.handlePPoGATTPacket(characteristic.getValue().clone());
} else if (characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) {
int battery_percent = ValueDecoder.decodePercent(characteristic, characteristic.getValue());
@@ -120,24 +97,19 @@ class PebbleGATTClient extends BluetoothGattCallback {
if (status == BluetoothGatt.GATT_SUCCESS) {
LOG.info("onCharacteristicRead() {} {}", characteristic.getUuid().toString(), GB.hexdump(characteristic.getValue(), 0, -1));
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)) {
// Delegate to pairing flow if applicable
if (mPairingFlow != null && mPairingFlow.onCharacteristicRead(gatt, characteristic, characteristic.getValue())) {
return; // Pairing flow handled it
}
// Handle non-pairing characteristics
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)) {
// 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 {
subscribeToConnectionParams(gatt);
}
}
}
}
@@ -162,7 +134,14 @@ class PebbleGATTClient extends BluetoothGattCallback {
if (mPebbleLESupport.isUnexpectedDevice(gatt.getDevice())) {
return;
}
if (characteristic.getUuid().equals(PPOGATT_CHARACTERISTIC_WRITE)) {
// Delegate to pairing flow if applicable
if (mPairingFlow != null && mPairingFlow.onCharacteristicWrite(gatt, characteristic, status)) {
return; // Pairing flow handled it
}
// Handle non-pairing characteristics
if (characteristic.getUuid().equals(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_WRITE)) {
if (status != BluetoothGatt.GATT_SUCCESS) {
LOG.error("something went wrong when writing to PPoGATT characteristics");
}
@@ -171,19 +150,7 @@ class PebbleGATTClient extends BluetoothGattCallback {
} else {
LOG.warn("mWaitWriteCompleteLatch is null!");
}
} 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 {
subscribeToConnectionParams(gatt);
}
} else if (characteristic.getUuid().equals(MTU_CHARACTERISTIC)) {
} else if (characteristic.getUuid().equals(PebbleGATTConstants.MTU_CHARACTERISTIC)) {
gatt.requestMtu(339);
}
}
@@ -200,17 +167,17 @@ class PebbleGATTClient extends BluetoothGattCallback {
// this is just a hack to force sequential ble commands for initialization
// kind of event driven
if (CHARACTERISTICUUID.equals(CONNECTION_PARAMETERS_CHARACTERISTIC)) {
if (CHARACTERISTICUUID.equals(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC)) {
subscribeToConnectivity(gatt);
} else if (CHARACTERISTICUUID.equals(CONNECTIVITY_CHARACTERISTIC)) {
} else if (CHARACTERISTICUUID.equals(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC)) {
subscribeToMTUOrBattery(gatt);
} else if (CHARACTERISTICUUID.equals(MTU_CHARACTERISTIC) || CHARACTERISTICUUID.equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) {
} else if (CHARACTERISTICUUID.equals(PebbleGATTConstants.MTU_CHARACTERISTIC) || CHARACTERISTICUUID.equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) {
if (mPebbleLESupport.clientOnly) {
subscribeToPPoGATT(gatt);
} else {
setMTU(gatt);
}
} else if (CHARACTERISTICUUID.equals(PPOGATT_CHARACTERISTIC_READ)) {
} else if (CHARACTERISTICUUID.equals(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ)) {
setMTU(gatt);
}
}
@@ -227,41 +194,35 @@ class PebbleGATTClient extends BluetoothGattCallback {
return;
}
BluetoothGattService pairingService = gatt.getService(SERVICE_UUID);
BluetoothGattService pairingService = gatt.getService(PebbleGATTConstants.SERVICE_UUID);
if (pairingService == null) {
LOG.error("Pairing service not found");
return;
}
// Detect device characteristics for subscription chain selection
BluetoothGattCharacteristic connectionParamCharacteristic =
pairingService.getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC);
pairingService.getCharacteristic(PebbleGATTConstants.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 {
// 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);
}
// Get device model for hardware-based pairing flow detection
String deviceModel = null;
try {
deviceModel = mPebbleLESupport.getPebbleSupport().getDevice().getModel();
} catch (Exception e) {
LOG.warn("Could not get device model: {}", e.getMessage());
}
// Create appropriate pairing flow
mPairingFlow = PebblePairingFlowFactory.createPairingFlow(
mContext, deviceModel, pairingService, mPebbleLESupport.clientOnly, this);
// Start pairing
mPairingFlow.startPairing(gatt, pairingService);
}
@Override
@@ -293,18 +254,18 @@ class PebbleGATTClient extends BluetoothGattCallback {
private void subscribeToConnectivity(BluetoothGatt gatt) {
LOG.info("subscribing to connectivity characteristic");
BluetoothGattDescriptor descriptor = gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTIVITY_CHARACTERISTIC).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTIVITY_CHARACTERISTIC), true);
gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC), true);
}
private void subscribeToMTU(BluetoothGatt gatt) {
BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC);
BluetoothGattCharacteristic characteristic = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC);
if (characteristic != null) {
LOG.info("subscribing to mtu characteristic");
BluetoothGattDescriptor descriptor = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC), true);
gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC), true);
} else {
LOG.info("Could not find MTU Characteristic. This seems to be a 2025 Pebble");
}
@@ -312,14 +273,14 @@ class PebbleGATTClient extends BluetoothGattCallback {
private void subscribeToConnectionParams(BluetoothGatt gatt) {
LOG.info("subscribing to connection parameters characteristic");
BluetoothGattDescriptor descriptor = gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC), true);
gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC), true);
}
private void subscribeToMTUOrBattery(BluetoothGatt gatt) {
// This is dumb, right now there is only one of them present in all pebbles
BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC);
BluetoothGattCharacteristic characteristic = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC);
if (characteristic != null) {
subscribeToMTU(gatt);
} else {
@@ -331,7 +292,7 @@ class PebbleGATTClient extends BluetoothGattCallback {
BluetoothGattCharacteristic characteristic = gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL);
if (characteristic != null) {
LOG.info("subscribing to battery characteristic");
BluetoothGattDescriptor descriptor = gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
BluetoothGattDescriptor descriptor = gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.setCharacteristicNotification(gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL), true);
} else {
@@ -341,9 +302,9 @@ class PebbleGATTClient extends BluetoothGattCallback {
private void setMTU(BluetoothGatt gatt) {
LOG.info("setting MTU");
BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC);
BluetoothGattCharacteristic characteristic = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC);
if (characteristic != null) {
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
descriptor.setValue(new byte[]{0x0b, 0x01}); // unknown
// descriptor is not wrote back to the device, but the characteristic is.
// Reason is unclear but writing back the descriptor instead of the characteristic breaks the connection.
@@ -355,10 +316,10 @@ class PebbleGATTClient extends BluetoothGattCallback {
private void subscribeToPPoGATT(BluetoothGatt gatt) {
LOG.info("subscribing to PPoGATT read characteristic");
BluetoothGattDescriptor descriptor = gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_READ).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.PPOGATT_SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR);
NotifyAction.writeDescriptor(gatt, descriptor, new byte[]{1, 0});
gatt.setCharacteristicNotification(gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_READ), true);
writeCharacteristics = gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_WRITE);
gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.PPOGATT_SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ), true);
writeCharacteristics = gatt.getService(PebbleGATTConstants.PPOGATT_SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_WRITE);
}
synchronized void sendDataToPebble(byte[] data) {
@@ -377,169 +338,11 @@ class PebbleGATTClient extends BluetoothGattCallback {
mWaitWriteCompleteLatch = null;
}
// ================== New Pairing Methods (based on libpebble3) ==================
// ================== Pairing Callback Implementation ==================
/**
* 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");
@Override
public void onPairingComplete(BluetoothGatt gatt, boolean hasConnectivityCharacteristics) {
LOG.info("Pairing complete, starting subscription chain (hasConnectivityChar={})", hasConnectivityCharacteristics);
if (hasConnectivityCharacteristics) {
subscribeToConnectivity(gatt);
} else {
@@ -547,70 +350,11 @@ class PebbleGATTClient extends BluetoothGattCallback {
}
}
/**
* 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 (mPairingFlow != null) {
mPairingFlow.close();
mPairingFlow = null;
}
if (mBluetoothGatt != null) {
mBluetoothGatt.disconnect();
mBluetoothGatt.close();
@@ -0,0 +1,40 @@
/* 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;
import java.util.UUID;
/**
* Shared GATT constants for Pebble BLE pairing flows.
*/
class PebbleGATTConstants {
static final UUID SERVICE_UUID = UUID.fromString("0000fed9-0000-1000-8000-00805f9b34fb");
static final UUID CONNECTIVITY_CHARACTERISTIC = UUID.fromString("00000001-328E-0FBB-C642-1AA6699BDADA");
static final UUID PAIRING_TRIGGER_CHARACTERISTIC = UUID.fromString("00000002-328E-0FBB-C642-1AA6699BDADA");
static final UUID MTU_CHARACTERISTIC = UUID.fromString("00000003-328e-0fbb-c642-1aa6699bdada");
static final UUID CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.fromString("00000005-328E-0FBB-C642-1AA6699BDADA");
static final UUID CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
// PPoGATT service (Pebble side)
static final UUID PPOGATT_SERVICE_UUID = UUID.fromString("30000003-328E-0FBB-C642-1AA6699BDADA");
static final UUID PPOGATT_CHARACTERISTIC_READ = UUID.fromString("30000004-328E-0FBB-C642-1AA6699BDADA");
static final UUID PPOGATT_CHARACTERISTIC_WRITE = UUID.fromString("30000006-328E-0FBB-C642-1AA6699BDADA");
private PebbleGATTConstants() {
// Utility class
}
}
@@ -0,0 +1,61 @@
/* 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;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattService;
/**
* Strategy interface for different Pebble BLE pairing flows.
* Different Pebble hardware versions require different pairing protocols.
*/
interface PebblePairingFlow {
/**
* Start the pairing sequence after service discovery.
*
* @param gatt The GATT connection
* @param pairingService The Pebble pairing service (0000fed9)
*/
void startPairing(BluetoothGatt gatt, BluetoothGattService pairingService);
/**
* Handle characteristic read callback during pairing.
*
* @param gatt The GATT connection
* @param characteristic The characteristic that was read
* @param value The value that was read
* @return true if this flow handled the read, false otherwise
*/
boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value);
/**
* Handle characteristic write callback during pairing.
*
* @param gatt The GATT connection
* @param characteristic The characteristic that was written
* @param status The write status (GATT_SUCCESS, etc.)
* @return true if this flow handled the write, false otherwise
*/
boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status);
/**
* Clean up any resources held by this pairing flow.
* Called when the GATT client is closed.
*/
void close();
}
@@ -0,0 +1,117 @@
/* 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;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattService;
import android.content.Context;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware;
/**
* Factory for creating the appropriate Pebble pairing flow based on device hardware and characteristics.
* <p>
* Device detection strategy:
* 1. Primary: Use hardware platform (via PebbleHardware.isBleOnlyByModel) to determine expected flow
* 2. Validation: Verify device has expected GATT characteristics
* 3. Defensive: If mismatch, adapt based on actual characteristics (e.g., firmware upgrade scenario)
* <p>
* Expected characteristics by hardware:
* - BLE-only Pebbles (Pebble 2, Time 2, 2 Duo): CONNECTIVITY_CHARACTERISTIC, no CONNECTION_PARAMETERS → ModernPebblePairingFlow
* - Dual-mode Pebbles (Pebble Time, Time Round): CONNECTION_PARAMETERS_CHARACTERISTIC → LegacyPebblePairingFlow
* - Classic Pebbles: No BLE pairing needed
*/
class PebblePairingFlowFactory {
private static final Logger LOG = LoggerFactory.getLogger(PebblePairingFlowFactory.class);
/**
* Create the appropriate pairing flow for the given device.
*
* @param context Android context for bonding operations
* @param deviceModel Device model string from GBDevice.getModel() (may be null)
* @param pairingService The Pebble pairing service (0000fed9)
* @param clientOnly Whether to use clientOnly mode (affects pairing trigger value)
* @param callback Callback to invoke when pairing is complete
* @return The appropriate pairing flow implementation
*/
static PebblePairingFlow createPairingFlow(
Context context,
@Nullable String deviceModel,
BluetoothGattService pairingService,
boolean clientOnly,
PairingCallback callback) {
// Detect GATT characteristics
BluetoothGattCharacteristic connectivityChar =
pairingService.getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC);
BluetoothGattCharacteristic connectionParamChar =
pairingService.getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC);
boolean hasConnectivityChar = (connectivityChar != null);
boolean hasConnectionParamChar = (connectionParamChar != null);
// Hardware-based expectation (if model is known)
Boolean hardwareExpectsModern = null; // null = unknown
if (deviceModel != null && !deviceModel.isEmpty()) {
hardwareExpectsModern = PebbleHardware.isBleOnlyByModel(deviceModel);
LOG.info("Hardware detection: model='{}', BLE-only={}", deviceModel, hardwareExpectsModern);
}
if (hardwareExpectsModern != null) {
if (hardwareExpectsModern && !hasConnectivityChar) {
LOG.warn("Mismatch: Hardware expects modern flow but CONNECTIVITY_CHARACTERISTIC missing (model={})", deviceModel);
}
if (!hardwareExpectsModern && hasConnectivityChar && !hasConnectionParamChar) {
LOG.warn("Mismatch: Hardware expects legacy flow but has modern characteristics (model={}) - possible firmware upgrade", deviceModel);
}
}
boolean useModernFlow;
if (hasConnectivityChar) {
// Modern characteristic present - use V2 flow
useModernFlow = true;
LOG.info("Using PebblePairingFlowV2 (CONNECTIVITY_CHARACTERISTIC present)");
} else if (hasConnectionParamChar) {
// Legacy characteristic present - use V1 flow
useModernFlow = false;
LOG.info("Using PebblePairingFlowV1 (CONNECTION_PARAMETERS_CHARACTERISTIC present)");
} else {
// No characteristics - fall back to hardware expectation or default to V1
useModernFlow = (hardwareExpectsModern != null && hardwareExpectsModern);
LOG.warn("No pairing characteristics found - using {} flow based on {}",
useModernFlow ? "V2" : "V1",
hardwareExpectsModern != null ? "hardware expectation" : "default");
}
if (useModernFlow) {
return new PebblePairingFlowV2(context, clientOnly, callback);
} else {
return new PebblePairingFlowV1(clientOnly, callback);
}
}
private PebblePairingFlowFactory() {
// Utility class
}
}
@@ -0,0 +1,123 @@
/* 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;
import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.WriteAction;
/**
* V1 Pebble pairing flow for older BLE-enabled devices (Pebble Time, Pebble Time Round).
* These devices use a simpler pairing protocol without connectivity status checking
* or explicit bonding management.
* <p>
* This is the original pairing flow from before commit 945e06946.
*/
@SuppressLint("MissingPermission")
class PebblePairingFlowV1 implements PebblePairingFlow {
private static final Logger LOG = LoggerFactory.getLogger(PebblePairingFlowV1.class);
private final boolean mClientOnly;
private final PairingCallback mCallback;
PebblePairingFlowV1(boolean clientOnly, PairingCallback callback) {
mClientOnly = clientOnly;
mCallback = callback;
}
@Override
public void startPairing(BluetoothGatt gatt, BluetoothGattService pairingService) {
LOG.info("PebblePairingFlowV1: Starting pairing for Pebble Time/Time Round");
BluetoothGattCharacteristic characteristic =
pairingService.getCharacteristic(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC);
if (characteristic == null) {
LOG.error("Pairing trigger characteristic not found");
// Proceed anyway to subscription chain
mCallback.onPairingComplete(gatt, false);
return;
}
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 (mClientOnly) {
value = new byte[]{0x11}; // needed in clientOnly mode
} else {
value = new byte[]{0x09}; // I just keep this, because it worked
}
WriteAction.writeCharacteristic(gatt, characteristic, value);
// Continue in onCharacteristicWrite()
} else {
LOG.info("This seems to be some <4.0 FW Pebble, reading pairing trigger");
gatt.readCharacteristic(characteristic);
// Continue in onCharacteristicRead()
}
}
@Override
public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) {
if (!characteristic.getUuid().equals(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC)) {
return false; // Not handled by this flow
}
// Legacy pairing trigger read complete - proceed to subscriptions
LOG.info("V1 pairing trigger read complete, proceeding to subscriptions");
proceedAfterPairing(gatt);
return true;
}
@Override
public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
if (!characteristic.getUuid().equals(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC)) {
return false; // Not handled by this flow
}
// Legacy pairing trigger write complete - proceed to subscriptions
LOG.info("V1 pairing trigger write complete (status={}), proceeding to subscriptions", status);
proceedAfterPairing(gatt);
return true;
}
@Override
public void close() {
// No resources to clean up for V1 flow
}
/**
* Continue with the subscription chain after pairing trigger is complete.
*/
private void proceedAfterPairing(BluetoothGatt gatt) {
LOG.info("PebblePairingFlowV1: Pairing complete, proceeding to subscriptions");
mCallback.onPairingComplete(gatt, false); // false = NO hasConnectivityCharacteristics (V1)
}
}
@@ -0,0 +1,279 @@
/* 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;
import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
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;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.WriteAction;
/**
* V2 Pebble pairing flow for BLE-only devices (Pebble 2, Time 2, 2 Duo).
* These devices require reading connectivity status, writing a pairing trigger,
* and initiating Bluetooth bonding before the connection can complete.
* <p>
* Based on the PebbleOS open source implementation and libpebble3.
*/
@SuppressLint("MissingPermission")
class PebblePairingFlowV2 implements PebblePairingFlow {
private static final Logger LOG = LoggerFactory.getLogger(PebblePairingFlowV2.class);
private static final long BONDING_TIMEOUT_MS = 60000; // 60 seconds
private final Context mContext;
private final boolean mClientOnly;
private final PairingCallback mCallback;
private ConnectivityStatus mConnectivityStatus;
private CountDownLatch mBondingLatch;
private BroadcastReceiver mBondingReceiver;
PebblePairingFlowV2(Context context, boolean clientOnly, PairingCallback callback) {
mContext = context;
mClientOnly = clientOnly;
mCallback = callback;
}
@Override
public void startPairing(BluetoothGatt gatt, BluetoothGattService pairingService) {
LOG.info("PebblePairingFlowV2: Starting pairing for Pebble 2/Time 2/2 Duo");
// Read Connectivity characteristic FIRST to determine pairing state
BluetoothGattCharacteristic connectivityChar =
pairingService.getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC);
if (connectivityChar != null) {
LOG.info("Reading connectivity characteristic to check pairing state");
gatt.readCharacteristic(connectivityChar);
// Continue in onCharacteristicRead() -> handleConnectivityRead()
} else {
LOG.error("Connectivity characteristic not found - cannot proceed with modern pairing");
// Proceed anyway to subscription chain
mCallback.onPairingComplete(gatt, true);
}
}
@Override
public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) {
if (!characteristic.getUuid().equals(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC)) {
return false; // Not handled by this flow
}
handleConnectivityRead(gatt, value);
return true;
}
@Override
public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
if (!characteristic.getUuid().equals(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC)) {
return false; // Not handled by this flow
}
// Pairing trigger written - now initiate Bluetooth bonding
LOG.info("Pairing trigger written successfully (status={}), initiating bond", status);
initiateBluetoothBond(gatt);
return true;
}
@Override
public void close() {
cleanupBondingReceiver();
}
/**
* 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(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.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
// 0x09 = pinAddress(1) + autoAccept(8) - worked for normal mode
// 0x11 = pinAddress(1) + watchAsServer(16) - for clientOnly mode
byte[] triggerValue;
if (mClientOnly) {
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 - shouldn't happen in modern flow)
LOG.warn("Pairing trigger not writable in modern flow - unexpected");
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("PebblePairingFlowV2: Pairing complete, proceeding to subscriptions");
mCallback.onPairingComplete(gatt, true); // true = hasConnectivityCharacteristics
}
}