I've been building a mobile app for a smartwatch, and the hardest
part wasn't the UI. It was getting the phone to find the watch,
connect over Bluetooth, and keep that connection alive.
The watch runs custom firmware. The app is React Native with Expo.
There's no SDK, no nice wrapper library from the hardware team.
Just a protocol spec and a lot of trial and error.
This post is about that connection layer only: scanning, pairing,
subscribing to the BLE channel, and handling drops. What you do
after you're connected (time sync, notifications, calibration) is
a separate story.
The setup
Most embedded BLE devices expose a UART-style service with one
characteristic for writing from the phone and one for receiving
notifications from the device. The exact UUIDs come from your
firmware docs; you plug them in once and move on.
On the React Native side I'm using react-native-ble-manager.
It doesn't hide much. You scan, connect, subscribe to
characteristics, and write raw bytes. That's actually fine once
you accept that's the job.
Everything lives in a custom hook called useBleManager that
wraps the library and exposes the functions the rest of the app
calls once a device is connected.
The handshake packet
Even the initial connection needs a properly formatted packet.
The firmware expects a binary frame: start marker, flag, command
code, sequence number, payload, checksum, end marker.
I use buildPayload to format the handshake before the first write:
export function buildPayload(
flag: number,
commandCode: number,
seq: number,
payload: string | number[],
) {
const dataBuffer =
typeof payload === "string" ? [...Buffer.from(payload)] : payload;
const escapedDataBuffer = escapePayload(dataBuffer);
const checksum = calculateChecksum([flag, commandCode, seq, ...dataBuffer]);
return Buffer.from([
FLAGS.START_BYTE,
flag,
commandCode,
seq,
...escapedDataBuffer,
checksum,
FLAGS.END_BYTE,
]).toJSON().data;
}
The checksum is calculated on the original payload, not the
escaped bytes you transmit. I lost a day to that.
Scanning for the device
Pairing starts when the user puts the watch into discoverable
mode on ours, that means holding a button until it vibrates.
The hook scans and filters devices by the name the firmware
advertises:
const startScan = async () => {
setIsScanning(true);
await BleManager.scan([], SCAN_DURATION, false, {
matchMode: BleScanMatchMode.Sticky,
scanMode: BleScanMode.LowLatency,
});
};
const handleDiscoverPeripheral = (peripheral: Peripheral) => {
if (peripheral.name?.toLowerCase().includes("your-device-name")) {
setPeripherals((map) => new Map(map.set(peripheral.id, peripheral)));
}
};
When the scan finishes, the user picks a device from the list.
Connecting
After the user taps a device, connectPeripheral runs the full
sequence:
const connectPeripheral = async (
peripheral: Peripheral,
callback: () => void
) => {
await BleManager.connect(peripheral.id);
await sleep(900);
await startPeripheralNotification(peripheral.id);
await sleep(1000);
callback();
};
The sleeps aren't optional decoration. BLE needs a moment after
connect before you start reading and writing. I tried skipping
them early on and got random failures that disappeared the moment
I added delays back.
startPeripheralNotification does the actual setup:
const startPeripheralNotification = async (deviceId: string) => {
await BleManager.retrieveServices(deviceId);
await BleManager.startNotification(deviceId, SERVICE_UUID, TX_UUID);
await BleManager.write(deviceId, SERVICE_UUID, RX_UUID, handshakePacket);
};
Three steps, always in this order:
- Retrieve services
- Start notifications on the TX characteristic
- Send the handshake write to the RX characteristic
That third step is what tells the firmware the phone is ready.
Without it, you're connected at the Bluetooth level but the
device doesn't know you're there.
Writing to the device
Once connected, every outgoing packet goes through the same path:
const writeToPeripheral = async (payload: number[]) => {
if (!selectedPeripheralRef.current?.peripheral?.connected) return;
await BleManager.write(
selectedPeripheralRef.current.peripheral.id,
SERVICE_UUID,
RX_UUID,
payload,
);
};
The hook keeps a ref to the currently connected device so any
screen can write without passing the device ID around.
Staying connected
Watches disconnect more than you'd expect. Users walk away,
phones lock, radios get busy.
When a drop happens, the disconnect listener fires and retry
kicks in:
const handleDisconnectedPeripheral = (
event: BleDisconnectPeripheralEvent
) => {
setPeripherals((map) => {
const device = map.get(event.peripheral);
if (device) device.connected = false;
return new Map(map);
});
const disconnected = peripherals.get(event.peripheral);
if (disconnected) {
connectPeripheralWithRetry(disconnected, () => {});
}
};
const connectPeripheralWithRetry = async (
peripheral: Peripheral,
callback: () => void
) => {
while (true) {
try {
await connectPeripheral(peripheral, callback);
break;
} catch (err) {
console.error("Connection failed, retrying...", err);
}
}
};
Auto-reconnect saved us from a lot of problems we never had to
debug in production.
Bootstrapping the hook
On mount, the hook starts the BLE manager, requests permissions,
and registers listeners:
useEffect(() => {
BleManager.start({ showAlert: true });
const listeners = [
bleManagerEmitter.addListener(
"BleManagerDiscoverPeripheral",
handleDiscoverPeripheral
),
bleManagerEmitter.addListener(
"BleManagerStopScan",
handleStopScan
),
bleManagerEmitter.addListener(
"BleManagerDisconnectPeripheral",
handleDisconnectedPeripheral
),
];
handleAndroidPermissions();
return () => listeners.forEach((listener) => listener.remove());
}, []);
Android permissions
iOS mostly works if you declare the Bluetooth usage description
in your Info.plist.
Android is another story. Before Android 12 you need location
permission to scan. From Android 12 onward you need separate
Bluetooth scan and connect permissions at runtime.
const handleAndroidPermissions = () => {
if (Platform.OS === "android" && Platform.Version >= 31) {
PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
]);
} else if (Platform.OS === "android" && Platform.Version >= 23) {
PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
);
}
};
I also used an Expo config plugin to inject the right manifest
entries. Without that, the app builds fine and then silently
fails to find any devices. Fun.
What I'd do differently
Log the handshake. Log every byte you send and receive during
the first connect. Most of my early bugs were visible in the hex
output within five minutes.
Don't skip the delays. If connect works sometimes but not
always, add a sleep before your first write. BLE timing is
finicky and fighting it costs more time than waiting.
Test disconnects on purpose. Walk away from your desk. Lock
your phone. Toggle airplane mode. Connection code that only works
on a happy path isn't done.
Keep connection logic in one hook. Scanning, connecting,
writing, and reconnecting all live in useBleManager. Screens
just call togglePeripheralConnection and move on.
Where it stands now
The app finds the device, connects, completes the handshake, and
reconnects when the link drops. Once that pipeline is stable,
everything else — time sync, notifications, calibration — builds
on top of the same writeToPeripheral path.
If you're trying to connect React Native to custom BLE firmware,
start here. Get scan, connect, and handshake working in isolation
before you touch any UI. The device doesn't care about your
component library. It only cares that you found it, stayed
connected, and sent the right bytes to say hello.
That's the whole game for this part.
Top comments (0)