{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreiadz2okcvuncrkhulhvno52m2kxleucw2qgxh276vlhmalqco2sai",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpeqt5tte422"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreiabh6dagfyzc6jdij6htsedldwo3yypyzzeg4qhxrqvnyptbavhz4"
    },
    "mimeType": "image/webp",
    "size": 302790
  },
  "path": "/ask_rabab_zahra/how-i-synced-ble-firmware-with-react-native-71p",
  "publishedAt": "2026-06-28T19:28:47.000Z",
  "site": "https://dev.to",
  "tags": [
    "reactnative",
    "bluetoothlowenergy",
    "javascript",
    "typescript"
  ],
  "textContent": "I've been building a mobile app for a smartwatch, and the hardest\npart wasn't the UI. It was getting the phone to find the watch,\nconnect over Bluetooth, and keep that connection alive.\n\nThe watch runs custom firmware. The app is React Native with Expo.\nThere's no SDK, no nice wrapper library from the hardware team.\nJust a protocol spec and a lot of trial and error.\n\nThis post is about that connection layer only: scanning, pairing,\nsubscribing to the BLE channel, and handling drops. What you do\nafter you're connected (time sync, notifications, calibration) is\na separate story.\n\n**The setup**\n\nMost embedded BLE devices expose a UART-style service with one\ncharacteristic for writing from the phone and one for receiving\nnotifications from the device. The exact UUIDs come from your\nfirmware docs; you plug them in once and move on.\n\nOn the React Native side I'm using `react-native-ble-manager`.\nIt doesn't hide much. You scan, connect, subscribe to\ncharacteristics, and write raw bytes. That's actually fine once\nyou accept that's the job.\n\nEverything lives in a custom hook called `useBleManager` that\nwraps the library and exposes the functions the rest of the app\ncalls once a device is connected.\n\n**The handshake packet**\n\nEven the initial connection needs a properly formatted packet.\nThe firmware expects a binary frame: start marker, flag, command\ncode, sequence number, payload, checksum, end marker.\n\nI use `buildPayload` to format the handshake before the first write:\n\n\n\n    export function buildPayload(\n      flag: number,\n      commandCode: number,\n      seq: number,\n      payload: string | number[],\n    ) {\n      const dataBuffer =\n        typeof payload === \"string\" ? [...Buffer.from(payload)] : payload;\n\n      const escapedDataBuffer = escapePayload(dataBuffer);\n      const checksum = calculateChecksum([flag, commandCode, seq, ...dataBuffer]);\n\n      return Buffer.from([\n        FLAGS.START_BYTE,\n        flag,\n        commandCode,\n        seq,\n        ...escapedDataBuffer,\n        checksum,\n        FLAGS.END_BYTE,\n      ]).toJSON().data;\n    }\n\n\nThe checksum is calculated on the original payload, not the\nescaped bytes you transmit. I lost a day to that.\n\n**Scanning for the device**\n\nPairing starts when the user puts the watch into discoverable\nmode on ours, that means holding a button until it vibrates.\n\nThe hook scans and filters devices by the name the firmware\nadvertises:\n\n\n\n    const startScan = async () => {\n      setIsScanning(true);\n      await BleManager.scan([], SCAN_DURATION, false, {\n        matchMode: BleScanMatchMode.Sticky,\n        scanMode: BleScanMode.LowLatency,\n      });\n    };\n\n    const handleDiscoverPeripheral = (peripheral: Peripheral) => {\n      if (peripheral.name?.toLowerCase().includes(\"your-device-name\")) {\n        setPeripherals((map) => new Map(map.set(peripheral.id, peripheral)));\n      }\n    };\n\n\nWhen the scan finishes, the user picks a device from the list.\n\n**Connecting**\n\nAfter the user taps a device, `connectPeripheral` runs the full\nsequence:\n\n\n\n    const connectPeripheral = async (\n      peripheral: Peripheral,\n      callback: () => void\n    ) => {\n      await BleManager.connect(peripheral.id);\n      await sleep(900);\n\n      await startPeripheralNotification(peripheral.id);\n      await sleep(1000);\n\n      callback();\n    };\n\n\nThe sleeps aren't optional decoration. BLE needs a moment after\nconnect before you start reading and writing. I tried skipping\nthem early on and got random failures that disappeared the moment\nI added delays back.\n\n`startPeripheralNotification` does the actual setup:\n\n\n\n    const startPeripheralNotification = async (deviceId: string) => {\n      await BleManager.retrieveServices(deviceId);\n      await BleManager.startNotification(deviceId, SERVICE_UUID, TX_UUID);\n      await BleManager.write(deviceId, SERVICE_UUID, RX_UUID, handshakePacket);\n    };\n\n\nThree steps, always in this order:\n\n  1. Retrieve services\n  2. Start notifications on the TX characteristic\n  3. Send the handshake write to the RX characteristic\n\n\n\nThat third step is what tells the firmware the phone is ready.\nWithout it, you're connected at the Bluetooth level but the\ndevice doesn't know you're there.\n\n**Writing to the device**\n\nOnce connected, every outgoing packet goes through the same path:\n\n\n\n    const writeToPeripheral = async (payload: number[]) => {\n      if (!selectedPeripheralRef.current?.peripheral?.connected) return;\n\n      await BleManager.write(\n        selectedPeripheralRef.current.peripheral.id,\n        SERVICE_UUID,\n        RX_UUID,\n        payload,\n      );\n    };\n\n\nThe hook keeps a ref to the currently connected device so any\nscreen can write without passing the device ID around.\n\n**Staying connected**\n\nWatches disconnect more than you'd expect. Users walk away,\nphones lock, radios get busy.\n\nWhen a drop happens, the disconnect listener fires and retry\nkicks in:\n\n\n\n    const handleDisconnectedPeripheral = (\n      event: BleDisconnectPeripheralEvent\n    ) => {\n      setPeripherals((map) => {\n        const device = map.get(event.peripheral);\n        if (device) device.connected = false;\n        return new Map(map);\n      });\n\n      const disconnected = peripherals.get(event.peripheral);\n      if (disconnected) {\n        connectPeripheralWithRetry(disconnected, () => {});\n      }\n    };\n\n    const connectPeripheralWithRetry = async (\n      peripheral: Peripheral,\n      callback: () => void\n    ) => {\n      while (true) {\n        try {\n          await connectPeripheral(peripheral, callback);\n          break;\n        } catch (err) {\n          console.error(\"Connection failed, retrying...\", err);\n        }\n      }\n    };\n\n\nAuto-reconnect saved us from a lot of problems we never had to\ndebug in production.\n\n**Bootstrapping the hook**\n\nOn mount, the hook starts the BLE manager, requests permissions,\nand registers listeners:\n\n\n\n    useEffect(() => {\n      BleManager.start({ showAlert: true });\n\n      const listeners = [\n        bleManagerEmitter.addListener(\n          \"BleManagerDiscoverPeripheral\",\n          handleDiscoverPeripheral\n        ),\n        bleManagerEmitter.addListener(\n          \"BleManagerStopScan\",\n          handleStopScan\n        ),\n        bleManagerEmitter.addListener(\n          \"BleManagerDisconnectPeripheral\",\n          handleDisconnectedPeripheral\n        ),\n      ];\n\n      handleAndroidPermissions();\n\n      return () => listeners.forEach((listener) => listener.remove());\n    }, []);\n\n\n**Android permissions**\n\niOS mostly works if you declare the Bluetooth usage description\nin your `Info.plist`.\n\nAndroid is another story. Before Android 12 you need location\npermission to scan. From Android 12 onward you need separate\nBluetooth scan and connect permissions at runtime.\n\n\n\n    const handleAndroidPermissions = () => {\n      if (Platform.OS === \"android\" && Platform.Version >= 31) {\n        PermissionsAndroid.requestMultiple([\n          PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,\n          PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,\n        ]);\n      } else if (Platform.OS === \"android\" && Platform.Version >= 23) {\n        PermissionsAndroid.request(\n          PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,\n        );\n      }\n    };\n\n\nI also used an Expo config plugin to inject the right manifest\nentries. Without that, the app builds fine and then silently\nfails to find any devices. Fun.\n\n**What I'd do differently**\n\n**Log the handshake.** Log every byte you send and receive during\nthe first connect. Most of my early bugs were visible in the hex\noutput within five minutes.\n\n**Don't skip the delays.** If connect works sometimes but not\nalways, add a sleep before your first write. BLE timing is\nfinicky and fighting it costs more time than waiting.\n\n**Test disconnects on purpose.** Walk away from your desk. Lock\nyour phone. Toggle airplane mode. Connection code that only works\non a happy path isn't done.\n\n**Keep connection logic in one hook.** Scanning, connecting,\nwriting, and reconnecting all live in `useBleManager`. Screens\njust call `togglePeripheralConnection` and move on.\n\n**Where it stands now**\n\nThe app finds the device, connects, completes the handshake, and\nreconnects when the link drops. Once that pipeline is stable,\neverything else — time sync, notifications, calibration — builds\non top of the same `writeToPeripheral` path.\n\nIf you're trying to connect React Native to custom BLE firmware,\nstart here. Get scan, connect, and handshake working in isolation\nbefore you touch any UI. The device doesn't care about your\ncomponent library. It only cares that you found it, stayed\nconnected, and sent the right bytes to say hello.\n\nThat's the whole game for this part.",
  "title": "How I synced BLE firmware with React Native"
}