O que vamos construir: um ESP32 anunciando um serviço BLE (GATT) com uma característica de temperatura que suporta leitura e notificação, e um app React Native — usando a lib react-native-ble-plx — que escaneia, encontra o dispositivo pelo UUID do serviço, conecta, lê o valor atual e recebe atualizações ao vivo a cada 2 segundos. Código completo dos dois lados, permissões de Android e iOS resolvidas, e a lista dos erros que mordem todo mundo na primeira vez.
Bluetooth Low Energy é a cola entre o mundo dos dispositivos e o mundo dos apps: é assim que balança conversa com aplicativo de treino, fechadura conversa com celular e — no nosso mundo — totem e sensor conversam com o app de campo do técnico. A documentação oficial é fragmentada (metade fala de firmware, metade fala de mobile, nenhuma fala das duas juntas), então este é o tutorial que gostaríamos de ter lido: as duas pontas, no mesmo texto, funcionando juntas.
Você vai precisar de: uma placa ESP32 DevKit (qualquer clone comum serve), um cabo USB de dados (metade dos problemas de gravação são cabo de tomada disfarçado), e um celular físico — emulador não tem rádio Bluetooth, então Android Studio/Xcode simulator ficam de fora dessa.
BLE em 5 minutos: o vocabulário mínimo
BLE tem fama de complicado porque os nomes são ruins, não porque o modelo seja difícil. O essencial:
| Termo | O que é | No nosso projeto |
|---|---|---|
| Peripheral | O dispositivo que anuncia e serve dados. | O ESP32 |
| Central | Quem escaneia, conecta e consome os dados. | O app React Native |
| Advertising | O "grito" periódico do peripheral: "existo, e ofereço isto". | O ESP32 anunciando o UUID do serviço |
| GATT | O contrato de dados sobre a conexão: serviços que contêm características. | 1 serviço, 1 característica |
| Serviço | Agrupador lógico de características, identificado por UUID. | "Serviço de telemetria" |
| Característica | O dado em si: um valor com propriedades (read, write, notify...). | A temperatura, como texto |
| Notify | O peripheral empurra o valor novo sem o app pedir. | Atualização a cada 2 s |
| Read | O app puxa o valor atual quando quer. | Leitura inicial ao conectar |
A diferença entre read e notify define a arquitetura: read é o app perguntando (puxa), notify é o dispositivo avisando (empurra). Para telemetria que muda com frequência, notify é o caminho — economiza rádio, bateria e código de polling. Nosso firmware implementa os dois.
Uma decisão antes de codar: UUIDs. Serviços padronizados (bateria, frequência cardíaca) têm UUIDs curtos definidos pelo Bluetooth SIG; serviço próprio usa UUID de 128 bits gerado por você (qualquer gerador de UUID v4 serve). Gere dois — um pro serviço, um pra característica — e use os mesmos nos dois lados. Os deste tutorial funcionam, mas gere os seus pra produção.
Passo 1 — O firmware: ESP32 como servidor GATT
Setup do ambiente
- Arduino IDE: em Settings → Additional Board Manager URLs, adicione a URL do pacote ESP32 da Espressif; depois em Boards Manager, instale "esp32 by Espressif Systems". Selecione a placa (pra DevKit genérico, "ESP32 Dev Module") e a porta serial que aparece ao plugar o cabo.
- PlatformIO (VS Code): crie um projeto com
board = esp32deveframework = arduino. Mesmo código, build mais rápido e dependências versionadas — é o que usamos em projeto de verdade.
A biblioteca BLE já vem com o core Arduino-ESP32 (a documentação oficial da Espressif cobre a API) — não precisa instalar nada além do board package.
O sketch completo
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
// Gere os SEUS UUIDs (v4) para produção — estes são do tutorial
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
const uint32_t NOTIFY_INTERVAL_MS = 2000;
BLEServer* server = nullptr;
BLECharacteristic* tempCharacteristic = nullptr;
bool deviceConnected = false;
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer*) override {
deviceConnected = true;
Serial.println("central conectada");
}
void onDisconnect(BLEServer* s) override {
deviceConnected = false;
Serial.println("central desconectada — voltando a anunciar");
s->getAdvertising()->start(); // sem isso, ninguém reconecta
}
};
void setup() {
Serial.begin(115200);
BLEDevice::init("RhodiumTemp"); // nome visível no scan
server = BLEDevice::createServer();
server->setCallbacks(new ServerCallbacks());
BLEService* service = server->createService(SERVICE_UUID);
tempCharacteristic = service->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
// CCCD (0x2902): descritor que permite à central LIGAR as notificações.
// Esquecer esta linha = notify silenciosamente não funciona.
tempCharacteristic->addDescriptor(new BLE2902());
service->start();
BLEAdvertising* advertising = BLEDevice::getAdvertising();
// ESSENCIAL: anunciar o UUID do serviço — é por ele que o app filtra o scan
advertising->addServiceUUID(SERVICE_UUID);
advertising->setScanResponse(true);
advertising->start();
Serial.println("BLE pronto, anunciando...");
}
void loop() {
if (deviceConnected) {
// Troque pela leitura real do seu sensor (DS18B20, SHT31...)
float temperature = 23.0f + (esp_random() % 100) / 25.0f;
char payload[16];
snprintf(payload, sizeof(payload), "%.2f", temperature);
tempCharacteristic->setValue(payload);
tempCharacteristic->notify();
Serial.printf("notify: %s\n", payload);
}
delay(NOTIFY_INTERVAL_MS);
}
Três pontos deste código pagam o artigo inteiro, porque são exatamente onde os fóruns estão cheios de gente travada:
- O descritor
BLE2902(CCCD) é o que permite ao app assinar as notificações. Sem ele, a conexão funciona, a leitura funciona — e o notify morre em silêncio. addServiceUUIDno advertising: é o que torna o dispositivo encontrável por filtro de serviço. Sem isso, o app precisa escanear o mundo inteiro e filtrar por nome — frágil e lento.- Reiniciar o advertising no
onDisconnect: o ESP32 para de anunciar quando alguém conecta. Se você não reiniciar ao desconectar, o dispositivo "some" pra sempre até o próximo reboot.
Grave o sketch (botão de upload; se a placa não entrar em modo de gravação sozinha, segure o botão BOOT durante o "Connecting..."), abra o Serial Monitor a 115200 e confirme o "BLE pronto, anunciando...". Antes de escrever qualquer linha do app, valide com um scanner genérico — o nRF Connect (Nordic, gratuito nas duas lojas) mostra o RhodiumTemp anunciando, o serviço, a característica, e deixa você ligar o notify na mão. Se funciona no nRF Connect e não funciona no seu app, o bug é do app; se não funciona nem lá, é do firmware. Essa bissecção economiza noites.
Passo 2 — O app: projeto React Native com react-native-ble-plx
Expo ou bare?
A react-native-ble-plx — a lib de referência pra BLE em React Native — exige código nativo, então não roda no Expo Go. Os dois caminhos que funcionam:
- Expo com development build (recomendado se você já vive no Expo): instale a lib e o
expo-dev-client, declare o config plugin, e gere o build de desenvolvimento comnpx expo run:android(ourun:ios). O plugin injeta as permissões nativas pra você. - Bare React Native:
npm install react-native-ble-plx,cd ios && pod install, e as permissões entram na mão (abaixo).
{
"expo": {
"plugins": [
[
"react-native-ble-plx",
{
"isBackgroundEnabled": false,
"modes": ["central"],
"bluetoothAlwaysPermission": "Este app usa Bluetooth para conectar ao sensor de temperatura."
}
]
]
}
}
Permissões — onde 90% dos tutoriais desatualizados quebram
O Android mudou o modelo de permissões de Bluetooth no Android 12: a documentação oficial define BLUETOOTH_SCAN e BLUETOOTH_CONNECT como permissões de runtime a partir da API 31 — e, pra versões anteriores, escanear BLE exige ACCESS_FINE_LOCATION (porque beacon revela localização). No iOS, a chave NSBluetoothAlwaysUsageDescription no Info.plist é obrigatória — sem ela o app crasha ao tocar no Bluetooth, sem mensagem útil.
<!-- Android 12+ (API 31) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 11 e anteriores -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Este app usa Bluetooth para conectar ao sensor de temperatura.</string>
O módulo BLE do app
Separe o BLE da interface — um módulo com o manager, as permissões e a decodificação. A lib trafega valores em base64, então instale também o polyfill buffer (npm install buffer):
import { BleManager } from "react-native-ble-plx";
import { PermissionsAndroid, Platform } from "react-native";
import { Buffer } from "buffer";
export const SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b";
export const CHARACTERISTIC_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8";
const ANDROID_12_API_LEVEL = 31;
// UMA instância pro app inteiro — criar mais de um BleManager causa bugs sutis
export const bleManager = new BleManager();
export async function requestBlePermissions(): Promise<boolean> {
if (Platform.OS !== "android") return true; // iOS pede sozinho no 1º uso
if (Number(Platform.Version) >= ANDROID_12_API_LEVEL) {
const result = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
]);
return Object.values(result).every(
(status) => status === PermissionsAndroid.RESULTS.GRANTED
);
}
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
}
export function decodeBase64(value: string | null | undefined): string {
if (!value) return "";
return Buffer.from(value, "base64").toString("utf-8");
}
O componente: escanear, conectar, assinar
import React, { useEffect, useRef, useState } from "react";
import { Button, FlatList, StyleSheet, Text, View } from "react-native";
import { Device, Subscription } from "react-native-ble-plx";
import {
bleManager, requestBlePermissions, decodeBase64,
SERVICE_UUID, CHARACTERISTIC_UUID,
} from "./ble";
type Status = "idle" | "scanning" | "connecting" | "connected" | "error";
export default function TempScreen() {
const [status, setStatus] = useState<Status>("idle");
const [devices, setDevices] = useState<Device[]>([]);
const [temperature, setTemperature] = useState<string>("--");
const monitorRef = useRef<Subscription | null>(null);
const deviceRef = useRef<Device | null>(null);
useEffect(() => {
// cleanup ao desmontar: para o monitor e desconecta
return () => {
monitorRef.current?.remove();
deviceRef.current?.cancelConnection().catch(() => {});
};
}, []);
async function startScan() {
const ok = await requestBlePermissions();
if (!ok) { setStatus("error"); return; }
setDevices([]);
setStatus("scanning");
// Filtrar pelo UUID do serviço: só aparece quem anuncia o nosso serviço
bleManager.startDeviceScan([SERVICE_UUID], null, (error, device) => {
if (error) { setStatus("error"); return; }
if (!device) return;
setDevices((prev) =>
prev.some((d) => d.id === device.id) ? prev : [...prev, device]
);
});
}
async function connectTo(device: Device) {
bleManager.stopDeviceScan();
setStatus("connecting");
try {
const connected = await device.connect();
// obrigatório antes de qualquer read/monitor:
await connected.discoverAllServicesAndCharacteristics();
deviceRef.current = connected;
// 1) leitura pontual do valor atual
const characteristic = await connected.readCharacteristicForService(
SERVICE_UUID, CHARACTERISTIC_UUID
);
setTemperature(decodeBase64(characteristic.value));
// 2) assinatura das notificações (a lib liga o CCCD pra você)
monitorRef.current = connected.monitorCharacteristicForService(
SERVICE_UUID, CHARACTERISTIC_UUID,
(error, char) => {
if (error) { setStatus("error"); return; }
setTemperature(decodeBase64(char?.value));
}
);
// 3) reagir à queda de conexão
connected.onDisconnected(() => {
monitorRef.current?.remove();
setStatus("idle");
setTemperature("--");
});
setStatus("connected");
} catch {
setStatus("error");
}
}
return (
<View style={styles.screen}>
<Text style={styles.temp}>{temperature} °C</Text>
<Text style={styles.status}>{status}</Text>
<Button title="Escanear" onPress={startScan} />
<FlatList
data={devices}
keyExtractor={(d) => d.id}
renderItem={({ item }) => (
<Text style={styles.device} onPress={() => connectTo(item)}>
{item.name ?? "(sem nome)"} — {item.id}
</Text>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, paddingTop: 80 },
temp: { fontSize: 56, fontWeight: "600", textAlign: "center" },
status: { textAlign: "center", marginBottom: 24, opacity: 0.6 },
device: { paddingVertical: 12, fontSize: 15 },
});
O fluxo completo, na ordem que o rádio vê: permissão → scan filtrado por serviço → connect → discover → read → monitor. O discoverAllServicesAndCharacteristics é obrigatório e esquecível — sem ele, read e monitor falham com erro de "characteristic not found" mesmo com tudo certo no firmware. E o monitor da lib já cuida de escrever no CCCD (aquele descritor BLE2902 do firmware) — os dois lados do tutorial se encaixam aqui.
Rode no celular físico (npx expo run:android / npx react-native run-android), toque em Escanear, conecte no RhodiumTemp — e a temperatura passa a atualizar sozinha a cada 2 segundos. Firmware empurrando, app ouvindo, zero polling.
Troubleshooting: o que morde de verdade
- "Preciso parear?" Não. GATT simples sem criptografia conecta direto pelo app, sem PIN e sem passar pelas configurações do sistema. Bonding só entra quando você habilita segurança nas características — e aí é outro tutorial.
- O scan não acha o dispositivo: quase sempre é o firmware que não incluiu o UUID no advertising (
addServiceUUID), ou o dispositivo está conectado a outra central (o nRF Connect que você esqueceu aberto conta) — peripheral conectado para de anunciar. - Funcionou ontem, hoje não: cache de GATT do Android. Se você mudou UUIDs ou estrutura do serviço no firmware, o Android pode continuar enxergando a tabela antiga. Desligar/ligar o Bluetooth do aparelho resolve na maioria dos casos.
- Payload cortado: o MTU padrão do BLE dá ~20 bytes úteis por pacote. Nosso payload de texto curto passa folgado; pra mandar mais, negocie MTU maior (
requestMTUForDevice) ou fragmente — e prefira payload binário a JSON verboso. - Notify não chega, read funciona: descritor CCCD ausente no firmware (o
BLE2902). É o erro clássico número um. - iOS crasha ao escanear: falta o
NSBluetoothAlwaysUsageDescription. E se o app precisar de BLE em segundo plano no iOS (reconectar com a tela apagada), é preciso habilitar o background mode de acessório Bluetooth — pra este tutorial, não precisa. - Emulador: não tem rádio. Celular físico, sempre.
Daqui pra produção
Esse par ESP32 + app é o "hello world" da mesma arquitetura que opera coisa séria: no nosso ecossistema de venda autônoma e dispositivos conectados — mais de 800 unidades em campo — BLE e outras camadas de rádio fazem papel de configuração local, diagnóstico de campo e comunicação entre módulos, enquanto a telemetria sobe por Wi-Fi/4G. A distância entre o tutorial e o produto está nas partes chatas: reconexão automática, atualização de firmware remota, segurança das características, e o suporte do dia 200. É exatamente essa ponte que a gente constrói pra clientes — começando, como todo projeto nosso, pela Fase 1: o Diagnóstico de Viabilidade Técnica.
Perguntas frequentes
Preciso parear o ESP32 com o celular para usar BLE?
Não. Para um servidor GATT simples sem criptografia, a conexão BLE acontece direto pelo app — sem passar pelas configurações de Bluetooth do sistema e sem PIN. Pareamento (bonding) só entra quando você habilita segurança nas características, com dados sensíveis ou comandos críticos.
Funciona com Expo ou preciso de projeto bare?
Funciona com Expo, mas não no Expo Go: a react-native-ble-plx exige código nativo, então é preciso um development build (expo-dev-client) com o config plugin da lib, ou um projeto bare React Native. O Expo Go não carrega módulos nativos de terceiros.
Qual o alcance do BLE com ESP32?
Em ambiente interno real, algo entre 10 e 30 metros, dependendo de paredes, interferência e antena da placa. BLE foi projetado para proximidade e baixo consumo — para longas distâncias a tecnologia certa é outra (LoRa, Wi-Fi, 4G), tema do nosso artigo de automação com IoT.
Consigo enviar comandos do app para o ESP32?
Sim. Basta criar uma segunda característica com a propriedade WRITE no firmware e usar writeCharacteristicWithResponseForDevice no app. O ESP32 recebe o valor num callback (onWrite) e age — liga um relé, muda um parâmetro, aciona um motor. É o mesmo padrão deste tutorial, na direção contrária.
Tem um produto que precisa de hardware conversando com app?
Do protótipo em bancada ao dispositivo em campo com atualização remota e suporte, o caminho tem armadilhas que só quem opera conhece. Na Fase 1 — o Diagnóstico de Viabilidade Técnica — colocamos sua ideia na mesa e você sai com o veredicto: arquitetura, custo de ciclo de vida e prazo. Sessão de até 3 horas, R$ 4.900, abatidos do projeto se você seguir.
Agendar meu Diagnóstico →Referências
- dotintent — react-native-ble-plx (repositório oficial)
- Espressif — Arduino-ESP32: Bluetooth Low Energy API
- Android Developers — Bluetooth permissions (BLUETOOTH_SCAN / BLUETOOTH_CONNECT)
- Apple Developer — NSBluetoothAlwaysUsageDescription
- Espressif — ESP32 Wi-Fi & Bluetooth SoC
Falar com a gente →